From 70b060ef1945fdc12b024e29400b3aa20a2c6ce5 Mon Sep 17 00:00:00 2001 From: Uniskela <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:32:50 +0000 Subject: [PATCH 1/5] feat: add commit and push workflow to Changes Wire the Changes tab to the Git workflow API so operators can select drafts, commit with their Account identity, and push or retry with plain-language remote-changed handling. --- docs/GIT_WORKFLOW.md | 2 +- docs/getting-started.md | 5 +- src/app/styles/components.css | 8 + src/app/styles/source.css | 36 ++ .../[repositoryId]/changes/page.tsx | 15 +- .../stacks/[stackId]/changes/page.tsx | 14 +- src/ui/api.ts | 59 +- src/ui/source/changes-view.tsx | 553 ++++++++++++++++-- tests/e2e/changes-commit.spec.ts | 269 +++++++++ 9 files changed, 902 insertions(+), 59 deletions(-) create mode 100644 tests/e2e/changes-commit.spec.ts 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..28d798a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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/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..150ce7f 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 (
c.path).join('\0')} workspaceId={workspaceId} + repositoryId={repositoryId} apiBase={repositorySourceApi(workspaceId, repositoryId)} rootPath="" changes={changes} editorHref={`/w/${workspaceId}/repositories/${repositoryId}/files`} + branch={repo.defaultBranch} + gitIdentity={identity} />
); diff --git a/src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx b/src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx index 68c34ae..1b16952 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 ( c.path).join('\0')} workspaceId={workspaceId} + repositoryId={stack.repository.id} apiBase={`/api/workspaces/${workspaceId}/stacks/${stackId}`} rootPath={stack.rootPath} changes={changes} editorHref={`/w/${workspaceId}/stacks/${stackId}`} + branch={stack.repository.defaultBranch} + gitIdentity={identity} + composePath={stack.composePath} /> ); } 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..c664fae 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,310 @@ 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 ( + +
    + {result.problems + .filter((p) => p.severity === 'error') + .map((p, i) => ( +
  • + {relativeTo(props.rootPath, p.path) || p.path} + {p.line > 0 ? `:${p.line}` : ''} — {problemLabel(p)} +
  • + ))} +
+
+ ); + } + if (result.status === 'warnings_unacknowledged') { + return ( + +
    + {result.problems + .filter((p) => p.severity === 'warning') + .map((p, i) => ( +
  • + {relativeTo(props.rootPath, p.path) || p.path} + {p.line > 0 ? `:${p.line}` : ''} — {problemLabel(p)} +
  • + ))} +
+ {props.onAcknowledge ? ( +

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. Fetch the latest changes before trying again. + {result.commitSha ? Local commit {shortSha(result.commitSha)} is retained. : null} +
+ Open repository to fetch +
+ {result.reason ? ( +

Technical detail: {result.reason} (non-fast-forward).

+ ) : 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 ?

Reason: {result.reason}.

: null} +
+ ); + } + return null; +} + +/** Review of draft changes in a stack or repository, with commit and optional push. */ export function ChangesView(props: { workspaceId: string; + repositoryId: string; /** Source API of the scope (see StackEditorProps.apiBase). */ apiBase: string; rootPath: string; changes: ChangeView[]; editorHref: string; + branch: string; + gitIdentity: GitIdentity | null; + /** Registered Compose path for stack-scoped validation; omit on repository Changes. */ + composePath?: string; }) { const router = useRouter(); - const [busy, setBusy] = useState(null); + const messageId = useId(); + const alertRef = useRef(null); + const [selected, setSelected] = useState(() => new Set(props.changes.map((c) => c.path))); + const [message, setMessage] = useState(''); + const [acknowledgeWarnings, setAcknowledgeWarnings] = useState(false); + const [busy, setBusy] = useState<'discard' | 'commit' | 'commit-push' | 'push' | null>(null); + const [discardPath, setDiscardPath] = useState(null); const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [retained, setRetained] = useState<{ + commitSha: string; + expectedRemoteSha: string; + branch: string; + } | null>(null); - if (props.changes.length === 0) { - return ( - - Edits you save in the Editor or Docs appear here for review before they are committed. - - ); - } + const gitBase = `/api/workspaces/${props.workspaceId}/repositories/${props.repositoryId}/git`; + const repositoryHref = `/w/${props.workspaceId}/repositories/${props.repositoryId}`; + const identityHref = `/w/${props.workspaceId}/settings/account#ws-account-git-identity`; + + useEffect(() => { + if (result || error) alertRef.current?.focus(); + }, [result, error]); + + const selectedChanges = useMemo( + () => props.changes.filter((c) => selected.has(c.path)), + [props.changes, selected], + ); + const clientProblems = useMemo( + () => + selectedChanges.flatMap((c) => + problemsFor(c.path, c.after, props.composePath).map((p) => ({ + path: c.path, + line: p.line, + column: p.column, + severity: p.severity, + code: (p.code ?? + (p.severity === 'error' + ? 'source_error' + : p.severity === 'warning' + ? 'source_warning' + : 'source_info')) as GitWorkflowProblem['code'], + })), + ), + [selectedChanges, props.composePath], + ); + const blockingErrors = clientProblems.filter((p) => p.severity === 'error'); + const warnings = clientProblems.filter((p) => p.severity === 'warning'); + const outdatedSelected = selectedChanges.filter((c) => c.outdated); + const canCommit = + !!props.gitIdentity && + selectedChanges.length > 0 && + message.trim().length > 0 && + blockingErrors.length === 0 && + outdatedSelected.length === 0 && + busy === null; + + const toggle = (path: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const applyResult = (next: GitWorkflowResult) => { + setResult(next); + if (next.commitSha && next.expectedRemoteSha && next.status !== 'push_succeeded') { + setRetained({ + commitSha: next.commitSha, + expectedRemoteSha: next.expectedRemoteSha, + branch: next.branch, + }); + } + if (next.status === 'push_succeeded') setRetained(null); + if (next.status === 'warnings_unacknowledged') setAcknowledgeWarnings(false); + }; const discard = async (path: string) => { - setBusy(path); + setBusy('discard'); + setDiscardPath(path); + setError(null); + try { + await api(`${props.apiBase}/drafts?path=${encodeURIComponent(path)}`, { method: 'DELETE' }); + router.refresh(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Could not reach the server.'); + } finally { + setBusy(null); + setDiscardPath(null); + } + }; + + const commit = async (push: boolean) => { + setBusy(push ? 'commit-push' : 'commit'); + setError(null); + setResult(null); + try { + const next = await gitWorkflow(`${gitBase}/commit`, { + method: 'POST', + body: { + paths: selectedChanges.map((c) => c.path), + message: message.trim(), + push, + acknowledgeWarnings: acknowledgeWarnings || undefined, + }, + }); + applyResult(next); + if (next.status === 'commit_succeeded' || next.status === 'push_succeeded') { + setMessage(''); + setAcknowledgeWarnings(false); + } + router.refresh(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Could not reach the server.'); + } finally { + setBusy(null); + } + }; + + const pushRetained = async () => { + if (!retained) return; + setBusy('push'); setError(null); + setResult(null); try { - await api(`${props.apiBase}/drafts?path=${encodeURIComponent(path)}`, { - method: 'DELETE', + const next = await gitWorkflow(`${gitBase}/push`, { + method: 'POST', + body: { + commitSha: retained.commitSha, + expectedRemoteSha: retained.expectedRemoteSha, + }, }); + applyResult(next); router.refresh(); } catch (err) { setError(err instanceof ApiError ? err.message : 'Could not reach the server.'); @@ -96,42 +381,234 @@ export function ChangesView(props: { } }; + if (props.changes.length === 0) { + return ( + + Edits you save in the Editor or Docs appear here for review before they are committed. + + ); + } + + const showWarningAck = + acknowledgeWarnings || warnings.length > 0 || result?.status === 'warnings_unacknowledged'; + const needsWarningAck = + (warnings.length > 0 || result?.status === 'warnings_unacknowledged') && !acknowledgeWarnings; + return ( -
- - 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} +
+
+ + {props.changes.length} draft{props.changes.length === 1 ? '' : 's'} + + {' · '} + {selectedChanges.length} selected for commit + {outdatedSelected.length ? ( + <> + {' · '} + {outdatedSelected.length} outdated + + ) : null} + {blockingErrors.length ? ( + <> + {' · '} + + {blockingErrors.length} blocking error{blockingErrors.length === 1 ? '' : 's'} + + + ) : null} + {warnings.length && !blockingErrors.length ? ( + <> + {' · '} + + {warnings.length} warning{warnings.length === 1 ? '' : 's'} + + + ) : null} +
+ + {!props.gitIdentity ? ( + + Set your name and email under Account settings before committing. Reads and drafts still work. +
+ + Open Git identity settings + +
) : null} + +
+ {error ? ( + + {error} + + ) : null} + {result ? ( + + ) : null} + {result?.commitSha && + result.status !== 'commit_succeeded' && + result.status !== 'push_succeeded' && + result.status !== 'validation_blocked' && + result.status !== 'warnings_unacknowledged' && + result.status !== 'draft_outdated' && + result.status !== 'git_identity_missing' ? ( + + Committed as {shortSha(result.commitSha)}. Your drafts are still on this server. + + ) : null} +
+ +
+
+
+ Commit +
+ {selectedChanges.length} file{selectedChanges.length === 1 ? '' : 's'} · branch{' '} + {props.branch} + {props.gitIdentity ? ( + <> + {' '} + · author{' '} + + {props.gitIdentity.name} <{props.gitIdentity.email}> + + + ) : null} +
+
+
+
{ + e.preventDefault(); + if (canCommit) void commit(false); + }} + > +
+ +