diff --git a/docs/GIT_WORKFLOW.md b/docs/GIT_WORKFLOW.md
index 5d86b18..7354b64 100644
--- a/docs/GIT_WORKFLOW.md
+++ b/docs/GIT_WORKFLOW.md
@@ -48,7 +48,7 @@ Edit → Validate → Commit → Push → (optional) Deploy → (optional) Watch
## Implemented provider foundation (PR #4)
-The low-level `GitProvider` supports `inspectBranch`, `commit`, `push`, and `getCommit` for all three smart-HTTP adapters. `GitWorkflowService` provides the application/API layer below. The final Changes UI is separate work.
+The low-level `GitProvider` supports `inspectBranch`, `commit`, `push`, and `getCommit` for all three smart-HTTP adapters. `GitWorkflowService` provides the application/API layer. The Changes tab commits selected drafts and can push or retry a retained commit.
## Application API contract (v0.5.0)
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 1042ad9..9897586 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -22,8 +22,8 @@ saved tokens, the audit log, your Git author identity, password and sessions.
stack-manager works with GitHub, Gitea and Forgejo over HTTPS.
-1. Create an access token on your Git server for the repository. Read access is enough for now; write access will be
- needed once committing from stack-manager is available.
+1. Create an access token on your Git server for the repository. Read access is enough to browse and draft; write
+ access is needed to push commits from the **Changes** tab.
2. In stack-manager, choose **Connect repository** and fill in:
- **Git provider** and the **HTTPS clone URL**. Never put the token in the URL.
- **Access token** (or a saved credential), plus a **username** only if your server requires one with the token.
@@ -80,7 +80,7 @@ Each stack has these tabs:
| **Editor** | Browse files in the explorer, open them in tabs and edit with syntax highlighting. Compose files are validated as you type and issues appear in the problems panel. **Save draft** (Ctrl+S / ⌘S) keeps your edit on the server. |
| **Docs** | Read the stack's Markdown files rendered (README first), follow links between them and edit them as drafts. |
| **Environment** | Variables the Compose file references, whether `.env.example` documents them, env files it expects, and values that look like hard-coded secrets. Values are never shown. |
-| **Changes** | Every draft as a line-by-line diff against the fetched commit. Discard the drafts you don't want. |
+| **Changes** | Every draft as a line-by-line diff against the fetched commit. Select files, commit with your Account Git identity, and push when ready. Discard drafts you don't want. |
| **Settings** | Rename the stack, change its Compose file or remove it (files and drafts are kept). |
Files that look like secrets (`.env`, private keys, certificates, anything in a `secrets/` folder) are listed but
@@ -91,7 +91,8 @@ can't be opened or edited.
Drafts are stored in stack-manager's database and never leave the server. If the file changes in Git after you start a
draft, the editor warns you, so you don't overwrite someone else's change unknowingly.
-Committing drafts, safe push and Git history ship in v0.5.0; see the [roadmap](plans/MVP_PLAN.md).
+Set your **Git identity** under Settings → Account before the first commit. After a successful push, drafts stay
+on the server until you discard them — fetch the repository if the remote moved ahead.
## 5. Files outside stacks
diff --git a/playwright.config.ts b/playwright.config.ts
index 160d086..01c71e1 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -19,16 +19,31 @@ export default defineConfig({
{
name: 'desktop',
testMatch: /\.spec\.ts/,
+ // Draft-mutating Git workflow tests share the sample stack; run them after source/shell.
+ testIgnore: /changes-commit\.spec\.ts/,
dependencies: ['onboarding'],
use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 860 } },
},
{
name: 'mobile',
testMatch: /\.spec\.ts/,
+ testIgnore: /changes-commit\.spec\.ts/,
dependencies: ['onboarding'],
// Pixel 7 is Chromium-based, so no WebKit download is needed.
use: { ...devices['Pixel 7'] },
},
+ {
+ name: 'git-workflow',
+ testMatch: /changes-commit\.spec\.ts/,
+ dependencies: ['desktop', 'mobile'],
+ use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 860 } },
+ },
+ {
+ name: 'git-workflow-mobile',
+ testMatch: /changes-commit\.spec\.ts/,
+ dependencies: ['git-workflow'],
+ use: { ...devices['Pixel 7'] },
+ },
],
webServer: process.env.E2E_BASE_URL
? undefined
diff --git a/src/app/styles/components.css b/src/app/styles/components.css
index 2676583..9a86f6f 100644
--- a/src/app/styles/components.css
+++ b/src/app/styles/components.css
@@ -398,7 +398,9 @@ button.list-row {
input[type='text'],
input[type='password'],
input[type='url'],
+input[type='email'],
input[type='search'],
+textarea,
select {
font: inherit;
width: 100%;
@@ -410,6 +412,12 @@ select {
border-radius: var(--radius-sm);
}
+textarea {
+ min-height: 5.5rem;
+ resize: vertical;
+ line-height: 1.45;
+}
+
input[aria-invalid='true'] {
border-color: var(--danger);
}
diff --git a/src/app/styles/source.css b/src/app/styles/source.css
index 97ec3d4..69998d9 100644
--- a/src/app/styles/source.css
+++ b/src/app/styles/source.css
@@ -740,6 +740,42 @@
border-bottom: 1px solid var(--border);
}
+.changes-summary {
+ font-size: 0.95rem;
+}
+
+.focus-target:focus {
+ outline: none;
+}
+
+.commit-panel .commit-form {
+ padding: var(--space-3) var(--space-4) var(--space-4);
+}
+
+.commit-actions {
+ padding-block: var(--space-2) 0;
+}
+
+.change-select {
+ flex: 1;
+ min-width: 0;
+}
+
+.problem-list {
+ margin: 0.35rem 0 0;
+ padding-left: 1.2rem;
+}
+
+.alert .actions {
+ margin-top: var(--space-3);
+}
+
+@media (max-width: 900px) {
+ .commit-actions .btn {
+ flex: 1 1 100%;
+ }
+}
+
/* ---------- Narrow screens ---------- */
@media (max-width: 900px) {
.ide {
diff --git a/src/app/w/[workspaceId]/repositories/[repositoryId]/changes/page.tsx b/src/app/w/[workspaceId]/repositories/[repositoryId]/changes/page.tsx
index 8386772..1259548 100644
--- a/src/app/w/[workspaceId]/repositories/[repositoryId]/changes/page.tsx
+++ b/src/app/w/[workspaceId]/repositories/[repositoryId]/changes/page.tsx
@@ -1,7 +1,8 @@
import type { Metadata } from 'next';
import { getContainer } from '@/server/container';
import { ChangesView } from '@/ui/source/changes-view';
-import { NotFetched, repositorySourceApi } from '../../../../../_lib/repository-source';
+import { requireSession } from '@/app/_lib/session';
+import { NotFetched, repositorySourceApi } from '@/app/_lib/repository-source';
export const metadata: Metadata = { title: 'Changes' };
@@ -12,18 +13,26 @@ export default async function RepositoryChangesPage({
params: Promise<{ workspaceId: string; repositoryId: string }>;
}) {
const { workspaceId, repositoryId } = await params;
- const { repositories, source } = getContainer();
+ const session = await requireSession();
+ const { repositories, source, auth } = getContainer();
const repo = await repositories.get(workspaceId, repositoryId);
if (!repo.headSha) return ;
- const changes = await source.changes(workspaceId, repositoryId, '');
+ const [changes, identity] = await Promise.all([
+ source.changes(workspaceId, repositoryId, ''),
+ auth.getGitIdentity(session.user.id),
+ ]);
return (
);
diff --git a/src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx b/src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx
index 68c34ae..3db8934 100644
--- a/src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx
+++ b/src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { getContainer } from '@/server/container';
import { ChangesView } from '@/ui/source/changes-view';
+import { requireSession } from '@/app/_lib/session';
export const metadata: Metadata = { title: 'Changes' };
@@ -10,16 +11,25 @@ export default async function StackChangesPage({
params: Promise<{ workspaceId: string; stackId: string }>;
}) {
const { workspaceId, stackId } = await params;
- const { stacks, source } = getContainer();
+ const session = await requireSession();
+ const { stacks, source, auth } = getContainer();
const stack = await stacks.get(workspaceId, stackId);
- const changes = await source.changes(workspaceId, stack.repository.id, stack.rootPath);
+ const [changes, identity] = await Promise.all([
+ source.changes(workspaceId, stack.repository.id, stack.rootPath),
+ auth.getGitIdentity(session.user.id),
+ ]);
return (
);
}
diff --git a/src/ui/api.ts b/src/ui/api.ts
index f78c17b..8ac7db9 100644
--- a/src/ui/api.ts
+++ b/src/ui/api.ts
@@ -1,5 +1,7 @@
'use client';
+import type { GitWorkflowResult } from '@/shared/git-workflow';
+
export class ApiError extends Error {
constructor(
readonly status: number,
@@ -11,8 +13,7 @@ export class ApiError extends Error {
}
}
-/** Same-origin JSON fetch. The session cookie is HTTP-only and sent automatically. */
-export async function api(path: string, init: { method?: string; body?: unknown } = {}): Promise {
+async function requestJson(path: string, init: { method?: string; body?: unknown } = {}) {
const res = await fetch(path, {
method: init.method ?? 'GET',
headers: init.body === undefined ? undefined : { 'Content-Type': 'application/json' },
@@ -20,16 +21,48 @@ export async function api(path: string, init: { method?: string; body?: unkno
credentials: 'same-origin',
cache: 'no-store',
});
- const data = await res.json().catch(() => ({}));
- if (!res.ok) {
- const err = (data as { error?: { code?: string; message?: string; fields?: Record } })
- .error;
- throw new ApiError(
- res.status,
- err?.code ?? 'error',
- err?.message ?? `Request failed (${res.status}).`,
- err?.fields,
- );
- }
+ const data: unknown = await res.json().catch(() => ({}));
+ return { res, data };
+}
+
+function throwApiError(status: number, data: unknown): never {
+ const err = (data as { error?: { code?: string; message?: string; fields?: Record } })
+ .error;
+ throw new ApiError(
+ status,
+ err?.code ?? 'error',
+ err?.message ?? `Request failed (${status}).`,
+ err?.fields,
+ );
+}
+
+/** Same-origin JSON fetch. The session cookie is HTTP-only and sent automatically. */
+export async function api(path: string, init: { method?: string; body?: unknown } = {}): Promise {
+ const { res, data } = await requestJson(path, init);
+ if (!res.ok) throwApiError(res.status, data);
return data as T;
}
+
+function isGitWorkflowResult(data: unknown): data is GitWorkflowResult {
+ return (
+ !!data &&
+ typeof data === 'object' &&
+ 'status' in data &&
+ 'draftsPreserved' in data &&
+ (data as GitWorkflowResult).draftsPreserved === true
+ );
+}
+
+/**
+ * Git workflow routes return a structured `GitWorkflowResult` for both success and
+ * expected conflict/validation outcomes (409/422/502). Malformed requests still use the error envelope.
+ */
+export async function gitWorkflow(
+ path: string,
+ init: { method?: string; body?: unknown } = {},
+): Promise {
+ const { res, data } = await requestJson(path, init);
+ if (isGitWorkflowResult(data)) return data;
+ if (!res.ok) throwApiError(res.status, data);
+ throw new ApiError(res.status, 'error', 'Unexpected Git workflow response.');
+}
diff --git a/src/ui/source/changes-view.tsx b/src/ui/source/changes-view.tsx
index 38dc69d..74468a7 100644
--- a/src/ui/source/changes-view.tsx
+++ b/src/ui/source/changes-view.tsx
@@ -4,15 +4,24 @@ import { structuredPatch } from 'diff';
import { GitCompareArrows } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
-import { useState } from 'react';
+import { useEffect, useId, useMemo, useRef, useState } from 'react';
+import type { GitWorkflowProblem, GitWorkflowResult } from '@/shared/git-workflow';
import { relativeTo } from '@/shared/source/paths';
+import { problemsFor } from '@/shared/source/problems';
import type { ChangeView } from '@/shared/source/types';
-import { api, ApiError } from '../api';
+import { api, ApiError, gitWorkflow } from '../api';
import { LocalTime } from '../local-time';
import { Alert } from '../primitives/alert';
+import { Button, ButtonLink } from '../primitives/button';
import { ConfirmButton } from '../primitives/confirm-button';
import { EmptyState } from '../primitives/empty-state';
+type GitIdentity = { name: string; email: string };
+
+function shortSha(sha: string) {
+ return sha.slice(0, 7);
+}
+
function Diff({ before, after }: { before: string; after: string }) {
const patch = structuredPatch('before', 'after', before, after, '', '', { context: 3 });
if (patch.hunks.length === 0) return
No textual changes.
;
@@ -60,34 +69,331 @@ function Diff({ before, after }: { before: string; after: string }) {
);
}
-/** Review of draft changes in a stack, as unified diffs against the version each draft started from. */
+function problemLabel(problem: GitWorkflowProblem) {
+ if (problem.code === 'hardcoded-secret') return 'Hard-coded secret';
+ if (problem.severity === 'error') return 'Error';
+ if (problem.severity === 'warning') return 'Warning';
+ return 'Info';
+}
+
+function WorkflowAlert(props: {
+ result: GitWorkflowResult;
+ editorHref: string;
+ rootPath: string;
+ repositoryHref: string;
+ identityHref: string;
+ onAcknowledge?: () => void;
+}) {
+ const { result } = props;
+ const fileLink = (path: string) => {
+ const rel = relativeTo(props.rootPath, path) || path;
+ return `${props.editorHref}?file=${encodeURIComponent(rel)}`;
+ };
+
+ if (result.status === 'push_succeeded' && result.commitSha) {
+ return (
+
+ Drafts are still on this server — review or discard them after you confirm the remote looks right.
+
+ );
+ }
+ if (result.status === 'commit_succeeded' && result.commitSha) {
+ return (
+
+ Drafts remain until you discard them. You can push this commit when ready.
+
+ );
+ }
+ if (result.status === 'validation_blocked') {
+ return (
+
+
Tick “Commit despite warnings” below, then try again.
+ ) : null}
+
+ );
+ }
+ if (result.status === 'git_identity_missing') {
+ return (
+
+ Set your name and email under Account settings before committing. Reads and drafts still work.
+
+
+ Open Git identity settings
+
+
+
+ );
+ }
+ if (result.status === 'draft_outdated') {
+ return (
+
+ These files changed upstream since the draft was started. Open them, review, and save again before
+ committing:{' '}
+ {result.outdatedPaths.map((path, i) => (
+
+ {i > 0 ? ', ' : ''}
+ {relativeTo(props.rootPath, path) || path}
+
+ ))}
+ .
+
+ );
+ }
+ if (result.status === 'remote_changed') {
+ return (
+
+ Nothing was overwritten and your drafts are still safe. Fetching only refreshes remote refs; it does
+ not reconcile a retained commit. Manual reconciliation must happen outside this app.
+ {result.commitSha ? Local commit {shortSha(result.commitSha)} is retained. : null}
+
+ ) : null}
+
+ );
+ }
+ if (result.status === 'push_rejected') {
+ return (
+
+ Nothing was overwritten and your drafts are still safe
+ {result.commitSha ? (
+
+ {' '}
+ — local commit {shortSha(result.commitSha)} is retained for retry after you review permissions or
+ policy.
+
+ ) : (
+ '.'
+ )}
+ {result.reason ?
Technical detail: {result.reason}.
: null}
+
+ );
+ }
+ if (result.status === 'branch_changed') {
+ return (
+
+ Nothing was overwritten and your drafts are still safe. Refresh and review before trying again.
+ {result.commitSha ? Local commit {shortSha(result.commitSha)} is retained. : null}
+
+ );
+ }
+ if (result.status === 'repository_busy') {
+ return (
+
+ Wait a moment and try again. Your drafts are still safe.
+
+ );
+ }
+ if (result.status === 'git_operation_failed') {
+ return (
+
+ Your drafts are still safe
+ {result.commitSha ? and local commit {shortSha(result.commitSha)} is retained. : '.'}
+ {result.reason ?
-
- Committing and pushing drafts to Git arrives in the next update. Until then, drafts are kept safely in
- stack-manager and never leave this server.
-
- {error ? (
-
- {error}
+