diff --git a/apps/launcher/src/standalone-runtime.ts b/apps/launcher/src/standalone-runtime.ts index ae53d3e..ff4c1eb 100644 --- a/apps/launcher/src/standalone-runtime.ts +++ b/apps/launcher/src/standalone-runtime.ts @@ -91,6 +91,8 @@ export async function startStandaloneRuntime( ), branchSearch: (request) => repositorySession!.searchBranches(request), + commitDrafts: (request) => + repositorySession!.updateDraft(request), snapshot: async () => { const result = await repositorySession!.requestRefresh(); return toProtocolRepositorySnapshot( @@ -169,6 +171,10 @@ async function dispatchRepositoryCommand( request: CommandEnvelope, ): Promise { if ( + request.command.kind === 'stage' || + request.command.kind === 'unstage' || + request.command.kind === 'commit' || + request.command.kind === 'cancel_operation' || request.command.kind === 'switch_branch' || request.command.kind === 'pull' || request.command.kind === 'push' || diff --git a/apps/server/src/protocol-dispatch.ts b/apps/server/src/protocol-dispatch.ts index b51932f..244ea34 100644 --- a/apps/server/src/protocol-dispatch.ts +++ b/apps/server/src/protocol-dispatch.ts @@ -218,10 +218,15 @@ export function createProtocolDispatcher( if (!request.ok) { return { status: 400, value: { error: request.error } }; } - return commitDraftResponse( - request.value.worktreeId, - await handlers.commitDrafts(request.value), - ); + try { + return commitDraftResponse( + request.value.worktreeId, + await handlers.commitDrafts(request.value), + ); + } catch (error) { + if (isStaleTargetError(error)) return staleDraftTargetResponse(); + throw error; + } } if (endpoint === 'commands' && handlers.commands !== undefined) { const input = parseJsonBody(body); @@ -382,6 +387,18 @@ function staleDiffTargetResponse(): ProtocolDispatchResponse { }; } +function staleDraftTargetResponse(): ProtocolDispatchResponse { + return { + status: 409, + value: { + error: { + code: 'stale_target', + message: 'The Commit Draft changed or its Worktree is unavailable.', + }, + }, + }; +} + function isStaleTargetError(error: unknown): boolean { return ( error instanceof Error && 'code' in error && error.code === 'stale_target' diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index b46c3d9..d3109a3 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -16,6 +16,21 @@ const loadingStore = createRepositoryStore({ mutateFiles: async () => { throw new Error('File mutations are unavailable while loading.'); }, + getCommitDraft: async () => { + throw new Error('Commit Drafts are unavailable while loading.'); + }, + updateCommitDraft: async () => { + throw new Error('Commit Drafts are unavailable while loading.'); + }, + commit: async () => { + throw new Error('Commit is unavailable while loading.'); + }, + recoverOperation: async () => { + throw new Error('Operation recovery is unavailable while loading.'); + }, + cancelOperation: async () => { + throw new Error('Operation cancellation is unavailable while loading.'); + }, searchBranches: async () => ({ refsRevision: 0, candidates: [] }), switchBranch: async () => { throw new Error('Branch switching is unavailable while loading.'); diff --git a/apps/ui/src/RepositoryOverview.interactions.test.tsx b/apps/ui/src/RepositoryOverview.interactions.test.tsx index 936ba3e..7cb5b51 100644 --- a/apps/ui/src/RepositoryOverview.interactions.test.tsx +++ b/apps/ui/src/RepositoryOverview.interactions.test.tsx @@ -770,6 +770,234 @@ describe('Repository overview interactions', () => { root = createRoot(container); }); + it('shows the exact Commit target and submits a synchronized multiline draft', async () => { + const fixture = createOverviewFixture('changed-worktree'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = current.snapshot.worktrees[0]!; + const updateCommitDraft = vi.fn( + async ( + request: Parameters[0], + ) => ({ + worktreeId: request.worktreeId, + revision: request.expectedRevision + 1, + text: request.update.kind === 'set' ? request.update.text : '', + }), + ); + const commit = vi.fn(async () => ({ + kind: 'succeeded' as const, + operationId: operationIdSchema.parse( + 'operation_00000000000000000000000000000003', + ), + result: { + kind: 'commit' as const, + shortObjectId: 'abcdef1', + summary: 'Commit title', + }, + })); + const store = createRepositoryStore({ + ...fixture.source, + updateCommitDraft, + commit, + }); + act(() => root.render()); + + expect(container.textContent).toContain(worktree.path); + expect(container.textContent).toContain('Local Branch main'); + expect(container.textContent).toContain('1 staged change'); + const submit = button('Commit staged changes in codex-git'); + expect(submit.disabled).toBe(true); + const draft = container.querySelector('textarea'); + if (!(draft instanceof HTMLTextAreaElement)) { + throw new Error('Missing Commit Draft'); + } + setInput(draft, 'Commit title\n\nCommit body'); + expect(submit.disabled).toBe(false); + + await act(async () => submit.click()); + + expect(updateCommitDraft).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: worktree.worktreeId, + update: { kind: 'set', text: 'Commit title\n\nCommit body' }, + }), + ); + expect(commit).toHaveBeenCalledWith( + { + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: 1, + confirmDetachedHead: false, + }, + expect.any(Function), + ); + expect(draft.value).toBe(''); + expect(container.textContent).toContain('Committed abcdef1: Commit title'); + }); + + it('explicitly clears only the selected Worktree Commit Draft', async () => { + const fixture = createOverviewFixture('many-worktrees'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const main = current.snapshot.worktrees.find(({ role }) => role === 'main'); + const linked = current.snapshot.worktrees.find( + ({ role }) => role === 'linked', + ); + if (main === undefined || linked === undefined) { + throw new Error('Expected Main and Linked Worktrees'); + } + const store = createRepositoryStore(fixture.source); + act(() => root.render()); + const mainDraft = container.querySelector('textarea'); + if (!(mainDraft instanceof HTMLTextAreaElement)) { + throw new Error('Missing Main Commit Draft'); + } + setInput(mainDraft, 'Clear this draft'); + act(() => + button(`Select ${linked.displayName} Worktree at ${linked.path}`).click(), + ); + const linkedDraft = container.querySelector('textarea'); + if (!(linkedDraft instanceof HTMLTextAreaElement)) { + throw new Error('Missing Linked Commit Draft'); + } + setInput(linkedDraft, 'Keep this draft'); + act(() => + button(`Select ${main.displayName} Worktree at ${main.path}`).click(), + ); + + act(() => button(`Clear Commit Draft for ${main.displayName}`).click()); + + expect( + (container.querySelector('textarea') as HTMLTextAreaElement).value, + ).toBe(''); + act(() => + button(`Select ${linked.displayName} Worktree at ${linked.path}`).click(), + ); + expect( + (container.querySelector('textarea') as HTMLTextAreaElement).value, + ).toBe('Keep this draft'); + }); + + it('does not reuse Detached HEAD confirmation across Worktrees at the same OID', () => { + const fixture = createOverviewFixture('many-worktrees'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const main = current.snapshot.worktrees.find(({ role }) => role === 'main'); + const linked = current.snapshot.worktrees.find( + ({ role }) => role === 'linked', + ); + if (main === undefined || linked === undefined) { + throw new Error('Expected Main and Linked Worktrees'); + } + const sharedObjectId = 'a'.repeat(40); + fixture.publish({ + kind: 'repository', + snapshot: { + ...current.snapshot, + worktrees: current.snapshot.worktrees.map((worktree) => + worktree.worktreeId === main.worktreeId || + worktree.worktreeId === linked.worktreeId + ? { + ...worktree, + head: { kind: 'detached' as const, objectId: sharedObjectId }, + status: { + kind: 'changed' as const, + conflictCount: 0, + stagedCount: 1, + trackedChangeCount: 0, + untrackedCount: 0, + }, + } + : worktree, + ), + }, + }); + const store = createRepositoryStore(fixture.source); + act(() => root.render()); + const confirmation = container.querySelector('input[type="checkbox"]'); + if (!(confirmation instanceof HTMLInputElement)) { + throw new Error('Missing Detached HEAD confirmation'); + } + + act(() => confirmation.click()); + expect(confirmation.checked).toBe(true); + act(() => + button(`Select ${linked.displayName} Worktree at ${linked.path}`).click(), + ); + + const linkedConfirmation = container.querySelector( + 'input[type="checkbox"]', + ); + expect(linkedConfirmation).toBeInstanceOf(HTMLInputElement); + expect((linkedConfirmation as HTMLInputElement).checked).toBe(false); + }); + + it('allows a second Worktree Commit while the first Worktree Commit is running', async () => { + const fixture = createOverviewFixture('many-worktrees'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const main = current.snapshot.worktrees.find(({ role }) => role === 'main'); + const linked = current.snapshot.worktrees.find( + ({ role }) => role === 'linked', + ); + if (main === undefined || linked === undefined) { + throw new Error('Expected Main and Linked Worktrees'); + } + fixture.publish({ + kind: 'repository', + snapshot: { + ...current.snapshot, + worktrees: current.snapshot.worktrees.map((worktree) => ({ + ...worktree, + status: { + kind: 'changed' as const, + conflictCount: 0, + stagedCount: 1, + trackedChangeCount: 0, + untrackedCount: 0, + }, + })), + }, + }); + const commit = vi.fn( + (request: Parameters[0]) => { + void request; + return new Promise(() => undefined); + }, + ); + const store = createRepositoryStore({ ...fixture.source, commit }); + act(() => root.render()); + const mainDraft = container.querySelector('textarea'); + if (!(mainDraft instanceof HTMLTextAreaElement)) { + throw new Error('Missing Main Commit Draft'); + } + setInput(mainDraft, 'Main running Commit'); + await act(async () => + button(`Commit staged changes in ${main.displayName}`).click(), + ); + await vi.waitFor(() => expect(commit).toHaveBeenCalledTimes(1)); + + act(() => + button(`Select ${linked.displayName} Worktree at ${linked.path}`).click(), + ); + const linkedDraft = container.querySelector('textarea'); + if (!(linkedDraft instanceof HTMLTextAreaElement)) { + throw new Error('Missing Linked Commit Draft'); + } + setInput(linkedDraft, 'Linked concurrent Commit'); + const linkedSubmit = button( + `Commit staged changes in ${linked.displayName}`, + ); + expect(linkedSubmit.disabled).toBe(false); + await act(async () => linkedSubmit.click()); + + await vi.waitFor(() => expect(commit).toHaveBeenCalledTimes(2)); + expect(commit.mock.calls.map(([request]) => request.worktreeId)).toEqual([ + main.worktreeId, + linked.worktreeId, + ]); + }); + function button(accessibleName: string): HTMLButtonElement { const element = container.querySelector( `button[aria-label="${accessibleName}"]`, diff --git a/apps/ui/src/RepositoryOverview.tsx b/apps/ui/src/RepositoryOverview.tsx index c5d54b0..8ee7c89 100644 --- a/apps/ui/src/RepositoryOverview.tsx +++ b/apps/ui/src/RepositoryOverview.tsx @@ -40,6 +40,14 @@ export function RepositoryOverview({ readonly worktreeId: string; readonly message: string; } | null>(null); + const [detachedCommitConfirmation, setDetachedCommitConfirmation] = useState({ + key: '', + confirmed: false, + }); + const detachedCommitConfirmationKey = selectedHeadKeyForConfirmation(state); + const detachedCommitConfirmed = + detachedCommitConfirmation.key === detachedCommitConfirmationKey && + detachedCommitConfirmation.confirmed; const orderedWorktrees = state.source.kind === 'repository' ? [...state.source.snapshot.worktrees].sort(compareWorktrees) @@ -123,6 +131,11 @@ export function RepositoryOverview({ const selected = snapshot.worktrees.find( (worktree) => worktree.worktreeId === state.selectedWorktreeId, ); + const selectedCommitOperation = + selected === undefined + ? ({ kind: 'idle' } as const) + : (state.commitOperations[selected.worktreeId] ?? + ({ kind: 'idle' } as const)); const selectedBranchName = selected?.head.kind === 'local_branch' ? selected.head.displayName : null; const selectedTerminalTarget = selected?.nativeTargets.find(({ actions }) => @@ -627,6 +640,92 @@ export function RepositoryOverview({ } /> +
+

Commit staged changes

+
+
+
Worktree
+
{selected.path}
+
+
+
HEAD
+
{commitHeadLabel(selected)}
+
+
+
Staged Changes
+
{stagedCount(selected)}
+
+
+ + {selected.head.kind !== 'detached' ? null : ( + + )} + + {selectedCommitOperation.kind === 'idle' ? null : ( +

+ {commitOperationLabel(selectedCommitOperation)} +

+ )} + {selectedCommitOperation.kind === 'running' && + selectedCommitOperation.operationId !== null ? ( + + ) : null} + {selectedCommitOperation.kind === 'result' && + selectedCommitOperation.result.kind === 'unknown_outcome' ? ( + + ) : null} +

Change Groups

0 && + draft.trim().length > 0 && + state.kind !== 'running' && + !(state.kind === 'result' && state.result.kind === 'unknown_outcome') && + (worktree.head.kind !== 'detached' || detachedConfirmed) + ); +} + +function commitOperationLabel( + state: import('./repository-store.js').CommitOperationState, +): string { + if (state.kind === 'idle') return ''; + if (state.kind === 'running') { + return state.cancellationRequested + ? 'Cancelling Commit and reconciling Git state…' + : 'Creating Commit…'; + } + if (state.kind === 'failed') return state.message; + if ( + state.result.kind === 'succeeded' && + state.result.result.kind === 'commit' + ) { + return `Committed ${state.result.result.shortObjectId}: ${state.result.result.summary}`; + } + return 'message' in state.result + ? state.result.message + : 'The Commit operation finished.'; +} + +function selectedHeadKeyForConfirmation( + state: import('./repository-store.js').RepositoryStoreSnapshot, +): string { + if (state.source.kind !== 'repository') return ''; + const selected = state.source.snapshot.worktrees.find( + ({ worktreeId }) => worktreeId === state.selectedWorktreeId, + ); + if (selected?.head.kind !== 'detached') return selected?.head.kind ?? ''; + return `${selected.worktreeId}:${selected.generation}:${selected.head.objectId}`; +} + function pullAllowed( worktree: WorktreeOverviewSnapshot, operations: RepositoryOverviewSnapshot['operations'], diff --git a/apps/ui/src/overview-fixtures.ts b/apps/ui/src/overview-fixtures.ts index cc2f3b4..24c109e 100644 --- a/apps/ui/src/overview-fixtures.ts +++ b/apps/ui/src/overview-fixtures.ts @@ -106,6 +106,29 @@ function createMutableFixture( async mutateFiles() { throw new Error('File mutations are not configured for this fixture.'); }, + async getCommitDraft(worktreeId) { + return { worktreeId, revision: 0, text: '' }; + }, + async updateCommitDraft(request) { + return { + worktreeId: request.worktreeId, + revision: request.expectedRevision + 1, + text: request.update.kind === 'set' ? request.update.text : '', + }; + }, + async commit() { + throw new Error('Commit is not configured for this fixture.'); + }, + async recoverOperation() { + throw new Error( + 'Operation recovery is not configured for this fixture.', + ); + }, + async cancelOperation() { + throw new Error( + 'Operation cancellation is not configured for this fixture.', + ); + }, async searchBranches() { return { refsRevision: 0, candidates: [] }; }, diff --git a/apps/ui/src/protocol-repository-source.test.ts b/apps/ui/src/protocol-repository-source.test.ts index 647cd7b..743164b 100644 --- a/apps/ui/src/protocol-repository-source.test.ts +++ b/apps/ui/src/protocol-repository-source.test.ts @@ -453,6 +453,106 @@ describe('ProtocolRepositorySource', () => { }); expect(result).toEqual(remoteResult); }); + + it('sends an explicit cancellation request for an accepted Operation', async () => { + const operationId = 'operation_0123456789abcdef0123456789abcdef'; + let commandBody: unknown; + const result = { + kind: 'unknown_outcome', + operationId, + code: 'reconciliation_incomplete', + message: 'Cancellation is reconciling.', + recoveryAvailable: true, + }; + const source = createProtocolRepositorySource({ + projectPath: '/projects/codex-git', + sessionUrl: 'http://127.0.0.1:4173/instance/fixture-token/v1/session', + createEventSource: () => new FakeEventSource(), + fetch: async (input, init) => { + const url = String(input); + if (url.endsWith('/session')) { + return jsonResponse({ + ...sessionMetadata, + capabilities: { + ...sessionMetadata.capabilities, + operationRecovery: true, + }, + }); + } + if (url.endsWith('/commands')) { + commandBody = JSON.parse(String(init?.body)); + return jsonResponse({ + operationId, + clientCommandId: (commandBody as { clientCommandId: string }) + .clientCommandId, + disposition: 'accepted', + }); + } + if (url.endsWith('/operations')) return jsonResponse(result); + return jsonResponse(repositorySnapshot); + }, + }); + await until(() => source.getSnapshot().kind === 'repository'); + + await expect( + source.cancelOperation( + operationId as import('@codex-git/protocol').OperationId, + ), + ).resolves.toEqual(result); + expect(commandBody).toEqual({ + clientCommandId: expect.stringMatching(/^command_[0-9a-f]{32}$/u), + command: { kind: 'cancel_operation', operationId }, + }); + }); + + it('refreshes the Repository snapshot after explicit Operation recovery', async () => { + const operationId = 'operation_0123456789abcdef0123456789abcdef'; + let snapshotRequests = 0; + const source = createProtocolRepositorySource({ + projectPath: '/projects/codex-git', + sessionUrl: 'http://127.0.0.1:4173/instance/fixture-token/v1/session', + createEventSource: () => new FakeEventSource(), + fetch: async (input) => { + const url = String(input); + if (url.endsWith('/session')) { + return jsonResponse({ + ...sessionMetadata, + capabilities: { + ...sessionMetadata.capabilities, + operationRecovery: true, + }, + }); + } + if (url.endsWith('/operations')) { + return jsonResponse({ + kind: 'succeeded', + operationId, + result: { + kind: 'commit', + shortObjectId: '1234567', + summary: 'Recovered Commit', + }, + }); + } + snapshotRequests += 1; + return jsonResponse({ + ...repositorySnapshot, + repositoryRevision: snapshotRequests, + }); + }, + }); + await until(() => source.getSnapshot().kind === 'repository'); + + await source.recoverOperation( + operationId as import('@codex-git/protocol').OperationId, + ); + + expect(snapshotRequests).toBe(2); + const state = source.getSnapshot(); + expect( + state.kind === 'repository' && state.snapshot.repositoryRevision, + ).toBe(2); + }); }); class FakeEventSource { diff --git a/apps/ui/src/protocol-repository-source.ts b/apps/ui/src/protocol-repository-source.ts index 3494f74..9dace05 100644 --- a/apps/ui/src/protocol-repository-source.ts +++ b/apps/ui/src/protocol-repository-source.ts @@ -9,6 +9,8 @@ import { diffResultSchema, nativeActionResultSchema, branchSearchResultSchema, + commitDraftSchema, + commitDraftUpdateSchema, repositorySnapshotResultSchema, sessionMetadataSchema, sseInvalidationSchema, @@ -51,6 +53,7 @@ export function createProtocolRepositorySource( let refresh: Promise | undefined; let snapshotInvalidated = false; let commandsAvailable = false; + let commitDraftsAvailable = false; let operationRecoveryAvailable = false; let latestFetchResult: import('@codex-git/protocol').OperationResult | undefined; @@ -111,6 +114,7 @@ export function createProtocolRepositorySource( .then((metadata) => { if (!active) return; commandsAvailable = metadata.capabilities.commands; + commitDraftsAvailable = metadata.capabilities.commitDrafts; operationRecoveryAvailable = metadata.capabilities.operationRecovery; if (metadata.capabilities.events) { events = createEventSource(endpointUrl(options.sessionUrl, 'events')); @@ -255,6 +259,66 @@ export function createProtocolRepositorySource( await requestSnapshot(); return result; }, + async getCommitDraft(worktreeId) { + if (!commitDraftsAvailable) { + throw new Error('Commit Drafts are unavailable.'); + } + return requestCommitDraft(fetcher, options.sessionUrl, { + kind: 'get', + worktreeId, + }); + }, + async updateCommitDraft(request) { + if (!commitDraftsAvailable) { + throw new Error('Commit Drafts are unavailable.'); + } + return requestCommitDraft(fetcher, options.sessionUrl, request); + }, + async commit(request, onAccepted) { + const command = { kind: 'commit', ...request } satisfies ProductCommand; + const receipt = await submitCommand( + fetcher, + options.sessionUrl, + commandEnvelopeSchema.parse({ + clientCommandId: createClientCommandId(), + command, + }), + ); + onAccepted?.(receipt.operationId); + if (!operationRecoveryAvailable) { + throw new Error('Commit recovery is unavailable.'); + } + const result = await recoverOperation( + fetcher, + options.sessionUrl, + receipt.operationId, + ); + await requestSnapshot(); + return result; + }, + async recoverOperation(operationId) { + if (!operationRecoveryAvailable) { + throw new Error('Operation recovery is unavailable.'); + } + const result = await recoverOperation( + fetcher, + options.sessionUrl, + operationId, + ); + await requestSnapshot(); + return result; + }, + async cancelOperation(operationId) { + const receipt = await submitCommand( + fetcher, + options.sessionUrl, + commandEnvelopeSchema.parse({ + clientCommandId: createClientCommandId(), + command: { kind: 'cancel_operation', operationId }, + }), + ); + return recoverOperation(fetcher, options.sessionUrl, receipt.operationId); + }, async searchBranches(worktreeId, query) { const response = await protocolPost( fetcher, @@ -412,6 +476,25 @@ async function recoverOperation( return operationResultSchema.parse(await response.json()); } +async function requestCommitDraft( + fetcher: typeof fetch, + sessionUrl: string, + request: unknown, +) { + const payload = commitDraftUpdateSchema.parse(request); + const response = await protocolFetch( + fetcher, + endpointUrl(sessionUrl, 'draft'), + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }, + ); + if (!response.ok) throw new Error('Commit Draft request failed.'); + return commitDraftSchema.parse(await response.json()); +} + function protocolFetch( fetcher: typeof fetch, url: string, @@ -443,6 +526,7 @@ function endpointUrl( | 'branches' | 'commands' | 'diff' + | 'draft' | 'events' | 'native-actions' | 'operations' diff --git a/apps/ui/src/repository-overview-model.ts b/apps/ui/src/repository-overview-model.ts index 8c4a8d3..0f2bee2 100644 --- a/apps/ui/src/repository-overview-model.ts +++ b/apps/ui/src/repository-overview-model.ts @@ -1,10 +1,12 @@ import type { DiffResult, + CommitDraft, FileId, NativeActionRequest, NativeActionResult, BranchSearchResult, OperationResult, + OperationId, RefId, RemoteId, RepositorySnapshot, @@ -113,6 +115,27 @@ export interface RepositoryOverviewSource { readonly expectedWorktreeRevision: number; readonly fileIds: readonly FileId[]; }): Promise; + getCommitDraft( + worktreeId: ProtocolWorktree['worktreeId'], + ): Promise; + updateCommitDraft(request: { + readonly worktreeId: ProtocolWorktree['worktreeId']; + readonly expectedRevision: number; + readonly update: + | { readonly kind: 'set'; readonly text: string } + | { readonly kind: 'clear' }; + }): Promise; + commit( + request: { + readonly worktreeId: ProtocolWorktree['worktreeId']; + readonly expectedWorktreeRevision: number; + readonly draftRevision: number; + readonly confirmDetachedHead: boolean; + }, + onAccepted?: (operationId: OperationId) => void, + ): Promise; + cancelOperation(operationId: OperationId): Promise; + recoverOperation(operationId: OperationId): Promise; searchBranches( worktreeId: ProtocolWorktree['worktreeId'], query: string, diff --git a/apps/ui/src/repository-store.test.ts b/apps/ui/src/repository-store.test.ts index da75f9d..236decf 100644 --- a/apps/ui/src/repository-store.test.ts +++ b/apps/ui/src/repository-store.test.ts @@ -6,6 +6,51 @@ import type { RepositoryOverviewSource } from './repository-overview-model.js'; import { createRepositoryStore } from './repository-store.js'; describe('RepositoryStore lifecycle', () => { + it('waits for the backend draft revision before persisting text typed during initial load', async () => { + const fixture = createOverviewFixture('changed-worktree'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = current.snapshot.worktrees[0]!; + let resolveDraft!: (draft: { + worktreeId: typeof worktree.worktreeId; + revision: number; + text: string; + }) => void; + const updateCommitDraft = vi.fn( + async ( + request: Parameters[0], + ) => ({ + worktreeId: request.worktreeId, + revision: request.expectedRevision + 1, + text: request.update.kind === 'set' ? request.update.text : '', + }), + ); + const source: RepositoryOverviewSource = { + ...fixture.source, + getCommitDraft: () => new Promise((resolve) => (resolveDraft = resolve)), + updateCommitDraft, + }; + const store = createRepositoryStore(source); + + store.setCommitDraft(worktree.worktreeId, 'Locally typed draft'); + expect(updateCommitDraft).not.toHaveBeenCalled(); + resolveDraft({ + worktreeId: worktree.worktreeId, + revision: 4, + text: 'Older backend draft', + }); + + await vi.waitFor(() => expect(updateCommitDraft).toHaveBeenCalled()); + expect(updateCommitDraft).toHaveBeenCalledWith({ + worktreeId: worktree.worktreeId, + expectedRevision: 4, + update: { kind: 'set', text: 'Locally typed draft' }, + }); + expect(store.getSnapshot().commitDrafts[worktree.worktreeId]).toBe( + 'Locally typed draft', + ); + }); + it('submits Push with the selected Worktree and observed revisions', async () => { const fixture = createOverviewFixture('one-worktree'); const requestRemoteOperation = vi.fn(async () => ({ @@ -166,4 +211,303 @@ describe('RepositoryStore lifecycle', () => { expect(store.getSnapshot().selectedFileId).toBe(stagedFileId); }); + + it('preserves a newer synchronized draft typed while Commit is running', async () => { + const fixture = createOverviewFixture('changed-worktree'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = current.snapshot.worktrees[0]!; + const pendingCommit = + deferred>>(); + const commit = vi.fn(() => pendingCommit.promise); + const store = createRepositoryStore({ ...fixture.source, commit }); + await vi.waitFor(() => + expect(store.getSnapshot().commitDrafts[worktree.worktreeId]).toBe(''), + ); + store.setCommitDraft(worktree.worktreeId, 'Submitted draft'); + await vi.waitFor(() => + expect(store.getSnapshot().commitDrafts[worktree.worktreeId]).toBe( + 'Submitted draft', + ), + ); + store.commit(false); + await vi.waitFor(() => expect(commit).toHaveBeenCalled()); + + store.setCommitDraft(worktree.worktreeId, 'New draft during Commit'); + pendingCommit.resolve({ + kind: 'succeeded', + operationId: operationIdSchema.parse( + 'operation_00000000000000000000000000000004', + ), + result: { + kind: 'commit', + shortObjectId: 'abcdef1', + summary: 'Submitted draft', + }, + }); + + await vi.waitFor(() => + expect( + store.getSnapshot().commitOperations[worktree.worktreeId], + ).toMatchObject({ kind: 'result', result: { kind: 'succeeded' } }), + ); + expect(store.getSnapshot().commitDrafts[worktree.worktreeId]).toBe( + 'New draft during Commit', + ); + }); + + it('tracks independent running Commits per Worktree', async () => { + const fixture = createOverviewFixture('many-worktrees'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const first = current.snapshot.worktrees.find( + ({ role }) => role === 'main', + ); + const second = current.snapshot.worktrees.find( + ({ role }) => role === 'linked', + ); + if (first === undefined || second === undefined) { + throw new Error('Expected two Worktrees'); + } + fixture.publish({ + kind: 'repository', + snapshot: { + ...current.snapshot, + worktrees: current.snapshot.worktrees.map((worktree) => ({ + ...worktree, + status: { + kind: 'changed' as const, + conflictCount: 0, + stagedCount: 1, + trackedChangeCount: 0, + untrackedCount: 0, + }, + })), + }, + }); + const pending = new Map>>(); + const commit = vi.fn( + (request: Parameters[0]) => { + const operation = deferred(); + pending.set(request.worktreeId, operation); + return operation.promise; + }, + ); + const store = createRepositoryStore({ ...fixture.source, commit }); + store.setCommitDraft(first.worktreeId, 'First Commit'); + store.setCommitDraft(second.worktreeId, 'Second Commit'); + store.commit(false); + await vi.waitFor(() => expect(commit).toHaveBeenCalledTimes(1)); + + store.selectWorktree(second.worktreeId); + store.commit(false); + await vi.waitFor(() => expect(commit).toHaveBeenCalledTimes(2)); + + expect(store.getSnapshot().commitOperations[first.worktreeId]).toEqual({ + kind: 'running', + operationId: null, + cancellationRequested: false, + }); + expect(store.getSnapshot().commitOperations[second.worktreeId]).toEqual({ + kind: 'running', + operationId: null, + cancellationRequested: false, + }); + expect(pending.size).toBe(2); + }); + + it('re-queries an Unknown Commit outcome and publishes later recovery', async () => { + const fixture = createOverviewFixture('changed-worktree'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = current.snapshot.worktrees[0]!; + const operationId = operationIdSchema.parse( + 'operation_00000000000000000000000000000005', + ); + let backendDraft = { revision: 0, text: '' }; + const updateCommitDraft = vi.fn( + async ( + request: Parameters[0], + ) => { + expect(request.expectedRevision).toBe(backendDraft.revision); + backendDraft = { + revision: backendDraft.revision + 1, + text: request.update.kind === 'set' ? request.update.text : '', + }; + return { worktreeId: request.worktreeId, ...backendDraft }; + }, + ); + const recoverOperation = vi.fn(async () => { + backendDraft = { revision: backendDraft.revision + 1, text: '' }; + return { + kind: 'succeeded' as const, + operationId, + result: { + kind: 'commit' as const, + shortObjectId: '1234567', + summary: 'Recovered Commit', + }, + }; + }); + const store = createRepositoryStore({ + ...fixture.source, + getCommitDraft: async (worktreeId) => ({ worktreeId, ...backendDraft }), + updateCommitDraft, + async commit() { + return { + kind: 'unknown_outcome', + operationId, + code: 'reconciliation_incomplete', + message: 'Reconciliation is incomplete.', + recoveryAvailable: true, + }; + }, + recoverOperation, + }); + store.setCommitDraft(worktree.worktreeId, 'Recover me'); + store.commit(false); + await vi.waitFor(() => + expect( + store.getSnapshot().commitOperations[worktree.worktreeId], + ).toMatchObject({ kind: 'result', result: { kind: 'unknown_outcome' } }), + ); + + store.recoverCommit(worktree.worktreeId); + + await vi.waitFor(() => + expect( + store.getSnapshot().commitOperations[worktree.worktreeId], + ).toMatchObject({ kind: 'result', result: { kind: 'succeeded' } }), + ); + expect(recoverOperation).toHaveBeenCalledWith(operationId); + expect(store.getSnapshot().commitDrafts[worktree.worktreeId]).toBe(''); + store.setCommitDraft(worktree.worktreeId, 'After recovery'); + await vi.waitFor(() => + expect(updateCommitDraft).toHaveBeenLastCalledWith( + expect.objectContaining({ expectedRevision: 2 }), + ), + ); + }); + + it('preserves and reloads a later draft when Unknown Commit recovery succeeds', async () => { + const fixture = createOverviewFixture('changed-worktree'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = current.snapshot.worktrees[0]!; + const operationId = operationIdSchema.parse( + 'operation_00000000000000000000000000000007', + ); + let backendDraft = { revision: 0, text: '' }; + const source: RepositoryOverviewSource = { + ...fixture.source, + async getCommitDraft(worktreeId) { + return { worktreeId, ...backendDraft }; + }, + async updateCommitDraft(request) { + if (request.expectedRevision !== backendDraft.revision) { + throw new Error('Stale draft'); + } + backendDraft = { + revision: backendDraft.revision + 1, + text: request.update.kind === 'set' ? request.update.text : '', + }; + return { worktreeId: request.worktreeId, ...backendDraft }; + }, + async commit() { + return { + kind: 'unknown_outcome', + operationId, + code: 'reconciliation_incomplete', + message: 'Reconciliation is incomplete.', + recoveryAvailable: true, + }; + }, + async recoverOperation() { + return { + kind: 'succeeded', + operationId, + result: { + kind: 'commit', + shortObjectId: '7654321', + summary: 'Original draft', + }, + }; + }, + }; + const store = createRepositoryStore(source); + store.setCommitDraft(worktree.worktreeId, 'Original draft'); + store.commit(false); + await vi.waitFor(() => + expect( + store.getSnapshot().commitOperations[worktree.worktreeId], + ).toMatchObject({ kind: 'result', result: { kind: 'unknown_outcome' } }), + ); + store.setCommitDraft(worktree.worktreeId, 'Later draft'); + await vi.waitFor(() => expect(backendDraft.text).toBe('Later draft')); + + store.recoverCommit(worktree.worktreeId); + + await vi.waitFor(() => + expect( + store.getSnapshot().commitOperations[worktree.worktreeId], + ).toMatchObject({ kind: 'result', result: { kind: 'succeeded' } }), + ); + expect(store.getSnapshot().commitDrafts[worktree.worktreeId]).toBe( + 'Later draft', + ); + expect(backendDraft).toEqual({ revision: 2, text: 'Later draft' }); + }); + + it('cancels an accepted Commit by its exact Operation ID and shows reconciliation', async () => { + const fixture = createOverviewFixture('changed-worktree'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = current.snapshot.worktrees[0]!; + const operationId = operationIdSchema.parse( + 'operation_00000000000000000000000000000006', + ); + const cancelOperation = vi.fn(async () => ({ + kind: 'unknown_outcome' as const, + operationId, + code: 'reconciliation_incomplete' as const, + message: 'Cancellation is reconciling.', + recoveryAvailable: true as const, + })); + const store = createRepositoryStore({ + ...fixture.source, + commit(_request, onAccepted) { + onAccepted?.(operationId); + return new Promise(() => undefined); + }, + cancelOperation, + }); + store.setCommitDraft(worktree.worktreeId, 'Cancel me'); + store.commit(false); + await vi.waitFor(() => + expect(store.getSnapshot().commitOperations[worktree.worktreeId]).toEqual( + { + kind: 'running', + operationId, + cancellationRequested: false, + }, + ), + ); + + store.cancelCommit(worktree.worktreeId); + + expect(cancelOperation).toHaveBeenCalledWith(operationId); + expect(store.getSnapshot().commitOperations[worktree.worktreeId]).toEqual({ + kind: 'running', + operationId, + cancellationRequested: true, + }); + }); }); + +function deferred() { + let resolve!: (value: Value) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} diff --git a/apps/ui/src/repository-store.ts b/apps/ui/src/repository-store.ts index 903c84f..81a2cd0 100644 --- a/apps/ui/src/repository-store.ts +++ b/apps/ui/src/repository-store.ts @@ -49,6 +49,16 @@ export type RemoteOperationState = } | { readonly kind: 'failed'; readonly message: string }; +export type CommitOperationState = + | { readonly kind: 'idle' } + | { + readonly kind: 'running'; + readonly operationId: import('@codex-git/protocol').OperationId | null; + readonly cancellationRequested: boolean; + } + | { readonly kind: 'result'; readonly result: OperationResult } + | { readonly kind: 'failed'; readonly message: string }; + import type { RepositoryOverviewSnapshot, RepositoryOverviewSource, @@ -60,7 +70,7 @@ export interface RepositoryStoreSnapshot { readonly source: RepositoryOverviewSourceState; readonly selectedWorktreeId: WorktreeId | null; readonly searchQuery: string; - readonly commitDrafts: Readonly>; + readonly commitDrafts: Readonly>>; readonly selectedFileId: FileId | null; readonly diff: DiffLoadState; readonly selectionNotice: string | null; @@ -68,6 +78,9 @@ export interface RepositoryStoreSnapshot { readonly branchPicker: BranchPickerState; readonly remoteOperation: RemoteOperationState; readonly fileMutationResult: OperationResult | null; + readonly commitOperations: Readonly< + Partial> + >; } export interface RepositoryStore { @@ -78,6 +91,10 @@ export interface RepositoryStore { selectWorktree(worktreeId: WorktreeId): void; setSearchQuery(query: string): void; setCommitDraft(worktreeId: WorktreeId, draft: string): void; + clearCommitDraft(worktreeId: WorktreeId): void; + commit(confirmDetachedHead: boolean): void; + cancelCommit(worktreeId: WorktreeId): void; + recoverCommit(worktreeId: WorktreeId): void; selectFile(fileId: FileId | null): void; requestRefresh(): void; requestFetch( @@ -106,7 +123,16 @@ export function createRepositoryStore( let selectedGeneration = initialWorktree?.generation ?? null; let selectedHeadKey = headSelectionKey(initialWorktree); let searchQuery = ''; - let commitDrafts: Readonly> = {}; + let commitDrafts: Readonly>> = {}; + const draftRevisions = new Map(); + const draftTouched = new Set(); + const draftLoads = new Map>(); + const draftWrites = new Map>(); + const draftWriteFailures = new Set(); + const commitSubmissions = new Map< + WorktreeId, + { readonly revision: number; readonly text: string } + >(); let selectedFileId: FileId | null = null; let diff: DiffLoadState = { kind: 'idle' }; let diffRequestGeneration = 0; @@ -116,12 +142,17 @@ export function createRepositoryStore( let branchRequestGeneration = 0; let remoteOperation: RemoteOperationState = { kind: 'idle' }; let fileMutationResult: OperationResult | null = null; + let commitOperations: Readonly< + Partial> + > = {}; let fileFollow: | { readonly displayPath: string; readonly kind: 'stage' | 'unstage' } | undefined; let storeSnapshot = buildSnapshot(); let disposed = false; + loadVisibleDrafts(sourceState); + const unsubscribeSource = source.subscribe(() => { if (disposed) return; const nextSource = source.getSnapshot(); @@ -132,6 +163,7 @@ export function createRepositoryStore( selected !== null && headSelectionKey(selected) !== selectedHeadKey; sourceState = nextSource; + loadVisibleDrafts(nextSource); if (identityChanged) { const replacement = selectInitialWorktree(nextSource); const previousSelection = selectedWorktreeId; @@ -219,7 +251,194 @@ export function createRepositoryStore( if (disposed) return; if (commitDrafts[worktreeId] === draft) return; commitDrafts = { ...commitDrafts, [worktreeId]: draft }; + draftTouched.add(worktreeId); + emit(); + queueDraftWrite(worktreeId); + }, + clearCommitDraft(worktreeId) { + if (disposed) return; + commitDrafts = { ...commitDrafts, [worktreeId]: '' }; + draftTouched.add(worktreeId); + emit(); + queueDraftWrite(worktreeId, true); + }, + commit(confirmDetachedHead) { + if (disposed || selectedWorktreeId === null) { + return; + } + const worktree = findWorktree(sourceState, selectedWorktreeId); + if ( + worktree === null || + commitOperations[worktree.worktreeId]?.kind === 'running' + ) { + return; + } + commitOperations = { + ...commitOperations, + [worktree.worktreeId]: { + kind: 'running', + operationId: null, + cancellationRequested: false, + }, + }; + emit(); + void (draftWrites.get(worktree.worktreeId) ?? Promise.resolve()) + .then(() => { + if (draftWriteFailures.has(worktree.worktreeId)) { + throw new Error('The Commit Draft could not be synchronized.'); + } + const draftRevision = draftRevisions.get(worktree.worktreeId); + if (draftRevision === undefined) { + throw new Error('The Commit Draft is not synchronized.'); + } + const submittedText = commitDrafts[worktree.worktreeId] ?? ''; + commitSubmissions.set(worktree.worktreeId, { + revision: draftRevision, + text: submittedText, + }); + return source + .commit( + { + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision, + confirmDetachedHead, + }, + (operationId) => { + if ( + disposed || + commitOperations[worktree.worktreeId]?.kind !== 'running' + ) { + return; + } + commitOperations = { + ...commitOperations, + [worktree.worktreeId]: { + kind: 'running', + operationId, + cancellationRequested: false, + }, + }; + emit(); + }, + ) + .then((result) => ({ + result, + submittedDraft: { revision: draftRevision, text: submittedText }, + })); + }) + .then(({ result, submittedDraft }) => { + if (disposed) return; + commitOperations = { + ...commitOperations, + [worktree.worktreeId]: { kind: 'result', result }, + }; + if ( + result.kind === 'succeeded' && + draftRevisions.get(worktree.worktreeId) === + submittedDraft.revision && + (commitDrafts[worktree.worktreeId] ?? '') === submittedDraft.text + ) { + commitDrafts = { ...commitDrafts, [worktree.worktreeId]: '' }; + draftRevisions.set( + worktree.worktreeId, + submittedDraft.revision + 1, + ); + } + if (result.kind !== 'unknown_outcome') { + commitSubmissions.delete(worktree.worktreeId); + } + emit(); + }) + .catch(() => { + if (disposed) return; + commitOperations = { + ...commitOperations, + [worktree.worktreeId]: { + kind: 'failed', + message: 'The Commit could not be submitted.', + }, + }; + emit(); + }); + }, + cancelCommit(worktreeId) { + const operation = commitOperations[worktreeId]; + if ( + disposed || + operation?.kind !== 'running' || + operation.operationId === null || + operation.cancellationRequested + ) { + return; + } + commitOperations = { + ...commitOperations, + [worktreeId]: { ...operation, cancellationRequested: true }, + }; + emit(); + void source.cancelOperation(operation.operationId).catch(() => { + if (disposed) return; + const current = commitOperations[worktreeId]; + if (current?.kind !== 'running') return; + commitOperations = { + ...commitOperations, + [worktreeId]: { ...current, cancellationRequested: false }, + }; + emit(); + }); + }, + recoverCommit(worktreeId) { + const operation = commitOperations[worktreeId]; + if ( + disposed || + operation?.kind !== 'result' || + operation.result.kind !== 'unknown_outcome' + ) { + return; + } + commitOperations = { + ...commitOperations, + [worktreeId]: { + kind: 'running', + operationId: null, + cancellationRequested: false, + }, + }; emit(); + void source + .recoverOperation(operation.result.operationId) + .then(async (result) => { + if (disposed) return; + if (result.kind === 'succeeded' && result.result.kind === 'commit') { + const submitted = commitSubmissions.get(worktreeId); + if (submitted !== undefined) { + await reconcileRecoveredDraft(worktreeId, submitted).catch(() => { + selectionNotice = + 'The Commit succeeded, but its Commit Draft could not be reloaded. Refresh and verify before editing.'; + }); + } + } + if (result.kind !== 'unknown_outcome') { + commitSubmissions.delete(worktreeId); + } + commitOperations = { + ...commitOperations, + [worktreeId]: { kind: 'result', result }, + }; + emit(); + }) + .catch(() => { + if (disposed) return; + commitOperations = { + ...commitOperations, + [worktreeId]: { + kind: 'failed', + message: 'Commit recovery could not refresh the outcome.', + }, + }; + emit(); + }); }, selectFile(fileId) { if (disposed) return; @@ -491,6 +710,7 @@ export function createRepositoryStore( branchPicker, remoteOperation, fileMutationResult, + commitOperations, }; } @@ -503,6 +723,106 @@ export function createRepositoryStore( diffRequestGeneration += 1; diff = { kind: 'idle' }; } + + function loadVisibleDrafts(next: RepositoryOverviewSourceState) { + if (next.kind !== 'repository') return; + for (const worktree of next.snapshot.worktrees) { + if (draftRevisions.has(worktree.worktreeId)) continue; + if (draftLoads.has(worktree.worktreeId)) continue; + const load = source + .getCommitDraft(worktree.worktreeId) + .then((draft) => { + if (disposed) return; + draftRevisions.set(worktree.worktreeId, draft.revision); + if (!draftTouched.has(worktree.worktreeId)) { + commitDrafts = { + ...commitDrafts, + [worktree.worktreeId]: draft.text, + }; + emit(); + } + if (draftTouched.has(worktree.worktreeId)) { + queueDraftWrite(worktree.worktreeId); + } + }) + .catch(() => { + draftWriteFailures.add(worktree.worktreeId); + }) + .finally(() => draftLoads.delete(worktree.worktreeId)); + draftLoads.set(worktree.worktreeId, load); + } + } + + function queueDraftWrite(worktreeId: WorktreeId, clear = false) { + if (draftWrites.has(worktreeId)) return; + const write = async () => { + await draftLoads.get(worktreeId); + while (!disposed) { + const expectedRevision = draftRevisions.get(worktreeId); + if (expectedRevision === undefined) { + throw new Error('The Commit Draft revision is unavailable.'); + } + const text = commitDrafts[worktreeId] ?? ''; + let draft; + try { + draft = await source.updateCommitDraft({ + worktreeId, + expectedRevision, + update: + clear && text.length === 0 + ? { kind: 'clear' } + : { kind: 'set', text }, + }); + } catch { + const current = await source.getCommitDraft(worktreeId); + draftRevisions.set(worktreeId, current.revision); + draft = await source.updateCommitDraft({ + worktreeId, + expectedRevision: current.revision, + update: + clear && text.length === 0 + ? { kind: 'clear' } + : { kind: 'set', text }, + }); + } + draftRevisions.set(worktreeId, draft.revision); + draftWriteFailures.delete(worktreeId); + if ((commitDrafts[worktreeId] ?? '') === draft.text) return; + clear = false; + } + }; + const pending = write() + .catch(() => { + draftWriteFailures.add(worktreeId); + selectionNotice = + 'The Commit Draft could not be synchronized. Commit remains unavailable.'; + emit(); + }) + .finally(() => draftWrites.delete(worktreeId)); + draftWrites.set(worktreeId, pending); + } + + async function reconcileRecoveredDraft( + worktreeId: WorktreeId, + submitted: { readonly revision: number; readonly text: string }, + ) { + await draftWrites.get(worktreeId); + const backend = await source.getCommitDraft(worktreeId); + if (disposed) return; + const localText = commitDrafts[worktreeId] ?? ''; + const unchanged = + draftRevisions.get(worktreeId) === submitted.revision && + localText === submitted.text; + draftRevisions.set(worktreeId, backend.revision); + if (unchanged) { + commitDrafts = { ...commitDrafts, [worktreeId]: backend.text }; + return; + } + if (backend.text !== localText) { + draftTouched.add(worktreeId); + queueDraftWrite(worktreeId); + } + } } function findWorktree( diff --git a/packages/protocol/src/commands.ts b/packages/protocol/src/commands.ts index dfe42b2..da99cf5 100644 --- a/packages/protocol/src/commands.ts +++ b/packages/protocol/src/commands.ts @@ -28,6 +28,7 @@ export const productCommandSchema = z.discriminatedUnion('kind', [ kind: z.literal('commit'), ...worktreeCommandBase, draftRevision: revisionSchema, + confirmDetachedHead: z.boolean(), }), z.strictObject({ kind: z.literal('switch_branch'), diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 023d9a4..ad893e8 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -84,17 +84,23 @@ export const branchSearchResultSchema = z.strictObject({ .readonly(), }); -export const commitDraftUpdateSchema = z.strictObject({ - worktreeId: worktreeIdSchema, - expectedRevision: revisionSchema, - update: z.discriminatedUnion('kind', [ - z.strictObject({ kind: z.literal('clear') }), - z.strictObject({ - kind: z.literal('set'), - text: utf8StringAtMost(PROTOCOL_LIMITS.draftBytes), - }), - ]), -}); +export const commitDraftUpdateSchema = z.union([ + z.strictObject({ + kind: z.literal('get'), + worktreeId: worktreeIdSchema, + }), + z.strictObject({ + worktreeId: worktreeIdSchema, + expectedRevision: revisionSchema, + update: z.discriminatedUnion('kind', [ + z.strictObject({ kind: z.literal('clear') }), + z.strictObject({ + kind: z.literal('set'), + text: utf8StringAtMost(PROTOCOL_LIMITS.draftBytes), + }), + ]), + }), +]); export const commitDraftSchema = z.strictObject({ worktreeId: worktreeIdSchema, diff --git a/packages/repository-engine/src/repository-engine.ts b/packages/repository-engine/src/repository-engine.ts index 5d9efbc..be8f6d6 100644 --- a/packages/repository-engine/src/repository-engine.ts +++ b/packages/repository-engine/src/repository-engine.ts @@ -132,6 +132,7 @@ interface CanonicalRegistration { export interface RepositoryEngineOptions { readonly metadata?: CodexMetadataAdapter; + readonly operationTimeoutMilliseconds?: number; } export function createRepositoryEngine( @@ -258,6 +259,7 @@ export function createRepositoryEngine( inspectFileMutationTargets: createFileMutationInspector(runGit), runGit, executeRemoteOperation, + operationTimeoutMilliseconds: options.operationTimeoutMilliseconds, }), ); }, @@ -768,6 +770,7 @@ function runGit( signal?: AbortSignal, maximumOutputBytes?: number, input?: Uint8Array, + environment?: Readonly>, ): Promise { return new Promise((resolvePromise, reject) => { const child = execFile( @@ -775,7 +778,7 @@ function runGit( [...args], { encoding: 'buffer', - env: createGitEnvironment(), + env: { ...createGitEnvironment(), ...environment }, maxBuffer: maximumOutputBytes ?? (allowLargeOutput ? GIT_OUTPUT_LIMIT_BYTES : 64 * 1_024), @@ -783,7 +786,7 @@ function runGit( timeout: GIT_TIMEOUT_MILLISECONDS, windowsHide: true, }, - (error, stdout) => { + (error, stdout, stderr) => { if (error !== null && error.code === acceptedEmptyExitCode) { resolvePromise(stdout); return; @@ -795,6 +798,7 @@ function runGit( error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? 'output_too_large' : 'command_failed', + classifyGitFailure(stderr), ), ); return; @@ -859,12 +863,35 @@ class GitCommandError extends Error { constructor( readonly exitCode: number | null, readonly failure: 'command_failed' | 'output_too_large', + readonly gitFailureCode: + 'hook_rejected' | 'signing_failed' | 'unclassified', ) { super('The Git process did not produce a valid local observation.'); this.name = 'GitCommandError'; } } +function classifyGitFailure( + stderr: string | Buffer, +): 'hook_rejected' | 'signing_failed' | 'unclassified' { + const diagnostic = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr; + if ( + /gpg failed to sign|failed to sign the data|signing failed/iu.test( + diagnostic, + ) + ) { + return 'signing_failed'; + } + if ( + /hook declined|hook failed|pre-commit|commit-msg|prepare-commit-msg/iu.test( + diagnostic, + ) + ) { + return 'hook_rejected'; + } + return 'unclassified'; +} + class WorktreeRegistrationMismatchError extends Error {} function isMissingPathError(error: unknown): error is NodeJS.ErrnoException { diff --git a/packages/repository-engine/src/repository-refresh.ts b/packages/repository-engine/src/repository-refresh.ts index 82f83db..e10c674 100644 --- a/packages/repository-engine/src/repository-refresh.ts +++ b/packages/repository-engine/src/repository-refresh.ts @@ -217,6 +217,7 @@ export function createRefreshingRepositorySession( resolveWorktreeNativeTarget: (targetId) => delegate.resolveWorktreeNativeTarget(targetId), searchBranches: (request) => delegate.searchBranches(request), + updateDraft: (request) => delegate.updateDraft(request), dispatch, cancelOperation: (operationId) => delegate.cancelOperation(operationId), recoverOperation: (operationId) => delegate.recoverOperation(operationId), diff --git a/packages/repository-engine/src/repository-session-commit.test.ts b/packages/repository-engine/src/repository-session-commit.test.ts new file mode 100644 index 0000000..7d2da19 --- /dev/null +++ b/packages/repository-engine/src/repository-session-commit.test.ts @@ -0,0 +1,451 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + AbsolutePath, + ClientCommandId, + WorktreeGeneration, + WorktreeId, +} from '@codex-git/protocol'; + +import { privateWorktreeIdentityEvidence } from './observation-publication.js'; +import type { ScopedRepositoryPublicationSession } from './repository-publication.js'; +import { + createRepositorySession, + type CommitTargetInspection, +} from './repository-session.js'; + +const worktreePath = '/worktree' as AbsolutePath; +const parentObjectId = '1'.repeat(40); +const committedObjectId = '2'.repeat(40); +const indexTree = '3'.repeat(40); + +describe('Repository Commit execution boundary', () => { + it('passes the exact multiline draft through stdin with the hook-preserving Git recipe', async () => { + const harness = createCommitHarness(); + const draftText = 'Exact title\n\nExact body\n'; + const draft = await harness.setDraft(draftText); + harness.onCommit = () => { + harness.headObjectId = committedObjectId; + }; + + const result = await harness.commit(draft.revision); + + expect(result).toMatchObject({ kind: 'succeeded' }); + const invocation = harness.calls.find(({ args }) => args[2] === 'commit'); + expect(invocation?.args).toEqual([ + '-C', + worktreePath, + 'commit', + '--file=-', + ]); + expect(new TextDecoder().decode(invocation?.input)).toBe(draftText); + expect(invocation?.environment).toEqual({ + GIT_INDEX_FILE: '/worktree/.git/index.codex-commit-private', + }); + const commitIndex = harness.calls.findIndex( + ({ args }) => args[2] === 'commit', + ); + expect(harness.calls[commitIndex + 1]?.args).toEqual([ + '-C', + worktreePath, + 'rev-list', + '--parents', + '-n', + '1', + `${committedObjectId.slice(0, 7)}^{commit}`, + ]); + await harness.session.close(); + }); + + it('does not attribute an external same-tree Commit to a failed Git process', async () => { + const harness = createCommitHarness(); + const draft = await harness.setDraft('Requested message'); + harness.onCommit = () => { + harness.headObjectId = committedObjectId; + harness.committedMessage = 'External message'; + throw knownGitFailure(); + }; + + const result = await harness.commit(draft.revision); + + expect(result).toMatchObject({ + kind: 'failed_known', + code: 'process_failed', + }); + expect(harness.calls.some(({ args }) => args[2] === 'rev-list')).toBe( + false, + ); + await expect( + harness.session.updateDraft({ + kind: 'get', + worktreeId: harness.worktreeId, + }), + ).resolves.toEqual(draft); + await harness.session.close(); + }); + + it('keeps success Unknown when the exact post-process HEAD candidate cannot be read', async () => { + const harness = createCommitHarness(); + const draft = await harness.setDraft('Candidate read failure'); + harness.onCommit = () => { + harness.headObjectId = committedObjectId; + harness.failHeadRead = true; + }; + + const result = await harness.commit(draft.revision); + + expect(result).toMatchObject({ kind: 'unknown_outcome' }); + await expect( + harness.session.updateDraft({ + kind: 'get', + worktreeId: harness.worktreeId, + }), + ).resolves.toEqual(draft); + await harness.session.close(); + }); + + it.each([ + ['Index lock', { indexLocked: true }, 'index_locked'], + ['HEAD replacement', { headObjectId: '4'.repeat(40) }, 'stale'], + ['Index replacement', { indexTree: '5'.repeat(40) }, 'stale'], + ['Worktree replacement', { worktreePath: '/replacement' }, 'stale'], + ] as const)( + 'rejects a raced %s during the final pre-execution inspection', + async (_label, change, expectedCode) => { + const harness = createCommitHarness(change); + const draft = await harness.setDraft('Race-safe Commit'); + + const result = await harness.commit(draft.revision); + + expect(result).toMatchObject({ kind: 'rejected', code: expectedCode }); + expect(harness.calls.some(({ args }) => args[2] === 'commit')).toBe( + false, + ); + await harness.session.close(); + }, + ); + + it('recovers a timed-out Commit after the process returns success and its captured HEAD remains selected', async () => { + const release = deferred(); + const harness = createCommitHarness({}, 10); + const draft = await harness.setDraft('Recovered timeout'); + harness.onCommit = async () => { + await release.promise; + harness.headObjectId = committedObjectId; + }; + + const receipt = await harness.dispatch(draft.revision); + await expect( + harness.session.recoverOperation(receipt.operationId), + ).resolves.toMatchObject({ kind: 'unknown_outcome' }); + + release.resolve(); + await expect + .poll(() => harness.session.recoverOperation(receipt.operationId)) + .toMatchObject({ kind: 'succeeded' }); + await harness.session.close(); + }); + + it('keeps timeout recovery Unknown when an external same-intent Commit has a different identity', async () => { + const externalObjectId = '6'.repeat(40); + const harness = createCommitHarness({}, 10); + const draft = await harness.setDraft('Same parent tree and message'); + harness.onCommit = async (signal) => { + await aborted(signal); + harness.headObjectId = externalObjectId; + throw new Error('The requested Git process did not return success.'); + }; + + const receipt = await harness.dispatch(draft.revision); + await expect( + harness.session.recoverOperation(receipt.operationId), + ).resolves.toMatchObject({ kind: 'unknown_outcome' }); + await expect( + harness.session.recoverOperation(receipt.operationId), + ).resolves.toMatchObject({ kind: 'unknown_outcome' }); + expect(harness.calls.some(({ args }) => args[2] === 'rev-list')).toBe( + false, + ); + await harness.session.close(); + }); + + it('reconciles cancellation to success when Git created the exact Commit before aborting', async () => { + const harness = createCommitHarness(); + const draft = await harness.setDraft('Cancelled after creation'); + harness.onCommit = async (signal) => { + harness.headObjectId = committedObjectId; + await aborted(signal); + }; + + const receipt = await harness.dispatch(draft.revision); + const cancellation = await harness.session.dispatch({ + clientCommandId: + 'command_00000000000000000000000000000002' as ClientCommandId, + command: { + kind: 'cancel_operation', + operationId: receipt.operationId, + }, + }); + const result = await harness.session.recoverOperation( + cancellation.operationId, + ); + + expect(result).toMatchObject({ kind: 'succeeded' }); + await harness.session.close(); + }); + + it('rejects a stage race captured after the real Index sentinel is acquired', async () => { + const harness = createCommitHarness(); + const draft = await harness.setDraft('Index raced before sentinel'); + harness.onBeginTransaction = () => { + harness.transactionIndexTree = '5'.repeat(40); + }; + + const result = await harness.commit(draft.revision); + + expect(result).toMatchObject({ kind: 'rejected', code: 'stale' }); + expect(harness.calls.some(({ args }) => args[2] === 'commit')).toBe(false); + expect(harness.cleanupCalls).toBe(1); + await harness.session.close(); + }); + + it('rejects a HEAD race captured after the real Index sentinel is acquired', async () => { + const harness = createCommitHarness(); + const draft = await harness.setDraft('HEAD raced before spawn'); + harness.onBeginTransaction = () => { + harness.headObjectId = '4'.repeat(40); + }; + + const result = await harness.commit(draft.revision); + + expect(result).toMatchObject({ kind: 'rejected', code: 'stale' }); + expect(harness.calls.some(({ args }) => args[2] === 'commit')).toBe(false); + expect(harness.cleanupCalls).toBe(1); + await harness.session.close(); + }); + + it('does not reconcile a successful process candidate with the wrong parent', async () => { + const harness = createCommitHarness(); + const draft = await harness.setDraft('Wrong parent candidate'); + harness.onCommit = () => { + harness.headObjectId = committedObjectId; + harness.committedParentObjectIds = ['7'.repeat(40)]; + }; + + const result = await harness.commit(draft.revision); + + expect(result).toMatchObject({ kind: 'unknown_outcome' }); + await expect( + harness.session.updateDraft({ + kind: 'get', + worktreeId: harness.worktreeId, + }), + ).resolves.toEqual(draft); + await harness.session.close(); + }); +}); + +function createCommitHarness( + inspectionChange: Partial = {}, + timeout?: number, +) { + const worktreeId = 'worktree_00000000000000000000000000000001' as WorktreeId; + const harness = { + headObjectId: parentObjectId, + committedMessage: 'Requested message', + committedParentObjectIds: [parentObjectId] as string[], + transactionIndexTree: indexTree, + failHeadRead: false, + cleanupCalls: 0, + onCommit: undefined as + ((signal: AbortSignal) => Promise | void) | undefined, + onBeginTransaction: undefined as (() => void) | undefined, + calls: [] as Array<{ + readonly args: readonly string[]; + readonly input: Uint8Array | undefined; + readonly environment: Readonly> | undefined; + }>, + }; + const repository = () => ({ + kind: 'repository' as const, + repository: fakeRepository(worktreeId, harness.headObjectId), + }); + const delegate = { + snapshot: async () => repository(), + requestRefresh: async () => repository(), + requestScopedRefresh: async () => repository(), + async *subscribe() {}, + close: async () => undefined, + } as unknown as ScopedRepositoryPublicationSession; + const runGit = vi.fn( + async ( + args: readonly string[], + _allowLargeOutput: boolean, + _acceptedEmptyExitCode?: 1, + signal: AbortSignal = new AbortController().signal, + _maximumOutputBytes?: number, + input?: Uint8Array, + environment?: Readonly>, + ) => { + harness.calls.push({ args, input, environment }); + if (args[2] === 'var') return bytes('Identity \n'); + if (args[2] === 'write-tree') { + return bytes( + `${environment === undefined ? indexTree : harness.transactionIndexTree}\n`, + ); + } + if (args[2] === 'rev-parse' && args.at(-1) === 'HEAD') { + return bytes(`${harness.headObjectId}\n`); + } + if (args[2] === 'rev-list') { + if (harness.failHeadRead) throw knownGitFailure(); + return bytes( + [harness.headObjectId, ...harness.committedParentObjectIds].join( + ' ', + ) + '\n', + ); + } + if (args[2] === 'commit') { + harness.committedMessage = new TextDecoder().decode(input); + await harness.onCommit?.(signal); + return bytes( + `[main ${harness.headObjectId.slice(0, 7)}] ${firstLine(harness.committedMessage)}\n`, + ); + } + throw new Error(`Unexpected Git invocation: ${args.join(' ')}`); + }, + ); + const session = createRepositorySession(delegate, { + operationTimeoutMilliseconds: timeout, + runGit, + inspectCommitTarget: async () => ({ + commonGitDirectory: '/common.git', + worktreePath, + headObjectId: parentObjectId, + indexTree, + indexPath: '/worktree/.git/index', + indexLocked: false, + ...inspectionChange, + }), + beginCommitIndexTransaction: async () => { + harness.onBeginTransaction?.(); + return { + environment: { + GIT_INDEX_FILE: '/worktree/.git/index.codex-commit-private', + }, + promote: async () => undefined, + cleanupKnownFailure: async () => { + harness.cleanupCalls += 1; + }, + }; + }, + }); + return Object.assign(harness, { + session, + worktreeId, + async setDraft(text: string) { + harness.committedMessage = text; + await session.snapshot(); + return session.updateDraft({ + worktreeId, + expectedRevision: 0, + update: { kind: 'set', text }, + }); + }, + dispatch(draftRevision: number) { + return session.dispatch({ + clientCommandId: + 'command_00000000000000000000000000000001' as ClientCommandId, + command: { + kind: 'commit', + worktreeId, + expectedWorktreeRevision: 1, + draftRevision, + confirmDetachedHead: false, + }, + }); + }, + async commit(draftRevision: number) { + const receipt = await this.dispatch(draftRevision); + return session.recoverOperation(receipt.operationId); + }, + }); +} + +function fakeRepository(worktreeId: WorktreeId, headObjectId: string) { + return { + repositoryId: 'repository_00000000000000000000000000000001', + commonGitDirectory: '/common.git', + selectedWorktreeId: worktreeId, + repositoryRevision: headObjectId === parentObjectId ? 1 : 2, + topologyRevision: 1, + refsRevision: 1, + refresh: { kind: 'fresh' as const }, + fetch: { kind: 'never' as const }, + remotes: [], + refs: [], + operations: [], + worktrees: [ + { + worktreeId, + worktreeRevision: 1, + generation: + 'generation_00000000000000000000000000000001' as WorktreeGeneration, + [privateWorktreeIdentityEvidence]: 'topology:/worktree', + displayPath: worktreePath, + canonicalPath: worktreePath, + role: 'main' as const, + head: { + kind: 'local_branch' as const, + fullName: 'refs/heads/main', + displayName: 'main', + objectId: headObjectId, + }, + gitLock: { kind: 'unlocked' as const }, + availability: { kind: 'available' as const }, + freshness: { kind: 'fresh' as const }, + index: { entryCount: 1, fingerprint: 'index-one', locked: false }, + status: { + clean: false, + conflicted: 0, + staged: 1, + unstaged: 0, + untracked: 0, + }, + changes: [], + upstream: { kind: 'unpublished' as const }, + }, + ], + }; +} + +function knownGitFailure() { + return Object.assign(new Error('Sensitive Git diagnostic.'), { + failure: 'command_failed', + exitCode: 1, + gitFailureCode: 'unclassified', + }); +} + +function bytes(value: string) { + return new TextEncoder().encode(value); +} + +function firstLine(value: string) { + return value.split(/\r?\n/u, 1)[0] ?? ''; +} + +function aborted(signal: AbortSignal) { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => + signal.addEventListener('abort', () => resolve(), { once: true }), + ); +} + +function deferred() { + let resolve!: (value: Value) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} diff --git a/packages/repository-engine/src/repository-session.ts b/packages/repository-engine/src/repository-session.ts index 6d42fc6..cec830e 100644 --- a/packages/repository-engine/src/repository-session.ts +++ b/packages/repository-engine/src/repository-session.ts @@ -1,3 +1,13 @@ +import { randomUUID } from 'node:crypto'; +import { + access, + open, + readFile, + realpath, + rename, + stat, + unlink, +} from 'node:fs/promises'; import { resolve, sep } from 'node:path'; import { @@ -6,6 +16,8 @@ import { type BranchSearchRequest, type BranchSearchResult, type CommandEnvelope, + type CommitDraft, + type CommitDraftUpdate, type DiffResult, type FileId, type NativeTargetId, @@ -16,6 +28,7 @@ import { type RefId, type RemoteId, type RepositoryId, + type WorktreeId, } from '@codex-git/protocol'; import { InvalidationStream } from './invalidation-stream.js'; @@ -47,6 +60,7 @@ export interface RepositorySession extends RepositoryPublicationSession { targetId: NativeTargetId, ): Promise; searchBranches(request: BranchSearchRequest): Promise; + updateDraft(request: CommitDraftUpdate): Promise; dispatch(request: CommandEnvelope): Promise; cancelOperation(operationId: OperationId): Promise; recoverOperation(operationId: OperationId): Promise; @@ -82,6 +96,14 @@ export interface RepositorySessionOptions { worktree: RepositorySnapshot['worktrees'][number], fileId: FileId, ) => Promise; + readonly operationTimeoutMilliseconds?: number; + readonly inspectCommitTarget?: ( + worktreePath: AbsolutePath, + signal: AbortSignal, + ) => Promise; + readonly beginCommitIndexTransaction?: ( + indexPath: string, + ) => Promise; } type FetchEffect = @@ -147,8 +169,31 @@ type GitProcessRunner = ( signal?: AbortSignal, maximumOutputBytes?: number, input?: Uint8Array, + environment?: Readonly>, ) => Promise; +export interface CommitTargetInspection { + readonly commonGitDirectory: string; + readonly worktreePath: string; + readonly headObjectId: string | null; + readonly indexTree: string; + readonly indexPath: string; + readonly indexLocked: boolean; +} + +export interface CommitIndexTransaction { + readonly environment: Readonly>; + promote(): Promise; + cleanupKnownFailure(): Promise; +} + +class CommitIndexLockExists extends Error { + constructor() { + super('The Worktree Index lock already exists.'); + this.name = 'CommitIndexLockExists'; + } +} + interface BranchBinding { readonly fullName: string; readonly kind: 'local' | 'remote_tracking'; @@ -224,6 +269,10 @@ export function createRepositorySession( let postOperationRefresh: Promise | undefined; let latestSuccessfulFetchAt: string | null = null; const remoteFetches = new Map(); + const commitDrafts = new Map< + WorktreeId, + { readonly revision: number; readonly text: string } + >(); let fetch = latest?.fetch ?? ({ kind: 'never' } as const); const publishCurrent = (base: RepositorySnapshot): RepositorySnapshot => { @@ -275,7 +324,8 @@ export function createRepositorySession( }; const operations = createOperationSession({ - operationTimeoutMilliseconds: OPERATION_TIMEOUT_MILLISECONDS, + operationTimeoutMilliseconds: + options.operationTimeoutMilliseconds ?? OPERATION_TIMEOUT_MILLISECONDS, publish(summary) { invalidations.publish({ kind: 'operation', operation: summary }); operationSummaries.set(summary.operationId, summary); @@ -962,6 +1012,34 @@ export function createRepositorySession( requestScopedRefresh: (scope: RepositoryRefreshScope) => observe(() => delegate.requestScopedRefresh(scope)), subscribe: () => invalidations.subscribe(), + async updateDraft(request) { + const worktree = latestBase?.worktrees.find( + ({ worktreeId }) => worktreeId === request.worktreeId, + ); + if (worktree === undefined) { + throw new RepositoryTargetFailure(); + } + const current = commitDrafts.get(request.worktreeId) ?? { + revision: 0, + text: '', + }; + if ('kind' in request) { + return { + worktreeId: request.worktreeId, + revision: current.revision, + text: current.text, + }; + } + if (request.expectedRevision !== current.revision) { + throw new RepositoryTargetFailure(); + } + const next = { + revision: current.revision + 1, + text: request.update.kind === 'set' ? request.update.text : '', + }; + commitDrafts.set(request.worktreeId, next); + return { worktreeId: request.worktreeId, ...next }; + }, async fetch(request) { const remoteIds = request.remoteId === null @@ -1381,6 +1459,15 @@ export function createRepositorySession( }; }, async dispatch(request) { + if (request.command.kind === 'cancel_operation') { + await operations.cancel(request.command.operationId); + await observe(() => delegate.requestRefresh()).catch(() => undefined); + return { + operationId: request.command.operationId, + clientCommandId: request.clientCommandId, + disposition: 'accepted', + }; + } if ( request.command.kind === 'pull' || request.command.kind === 'push' || @@ -1769,6 +1856,378 @@ export function createRepositorySession( disposition: 'accepted', }; } + if (request.command.kind === 'commit') { + const command = request.command; + const initial = latestBase?.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ); + if (initial === undefined) { + throw new Error('Commit requires a current Worktree snapshot.'); + } + const initialRepositoryId = latestBase?.repositoryId; + const initialCommonGitDirectory = latestBase?.commonGitDirectory; + const initialGeneration = initial.generation; + const initialCanonicalPath = initial.canonicalPath; + const initialHead = initial.head; + const initialIndexFingerprint = initial.index?.fingerprint ?? null; + const attachedRef = + initialHead.kind === 'local_branch' ? initialHead.fullName : null; + let commitIntent: + | { + readonly summary: string; + successfulObjectId: string | null; + successfulParentObjectIds: readonly string[] | null; + } + | undefined; + const admission = await operations.dispatch({ + kind: 'commit', + worktreeGeneration: initialGeneration, + attachedRef, + async reconcileBusy() { + await observe(() => delegate.requestRefresh()).catch( + () => undefined, + ); + }, + async execute({ signal }) { + const current = await observe(() => + delegate.requestRefresh(), + ).catch(() => undefined); + const worktree = + current?.kind === 'repository' + ? current.repository.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ) + : undefined; + const draft = commitDrafts.get(command.worktreeId) ?? { + revision: 0, + text: '', + }; + if ( + current?.kind === 'repository' && + worktree !== undefined && + (worktree.index?.locked === true || + worktree.gitLock.kind !== 'unlocked') + ) { + return reject( + 'index_locked', + 'The Worktree Index is locked by another Git process.', + ); + } + if ( + current?.kind !== 'repository' || + current.repository.repositoryId !== initialRepositoryId || + current.repository.commonGitDirectory !== + initialCommonGitDirectory || + worktree === undefined || + worktree.generation !== initialGeneration || + worktree.canonicalPath !== initialCanonicalPath || + worktree.worktreeRevision !== command.expectedWorktreeRevision || + !sameHead(worktree.head, initialHead) || + worktree.index?.fingerprint !== initialIndexFingerprint || + draft.revision !== command.draftRevision + ) { + return reject( + 'stale', + 'Worktree, HEAD, Index, or Commit Draft changed; refresh and try again.', + ); + } + if (draft.text.trim().length === 0) { + return reject( + 'precondition_failed', + 'Commit requires a non-empty Commit Draft.', + ); + } + if ( + worktree.head.kind === 'detached' && + !command.confirmDetachedHead + ) { + return reject( + 'precondition_failed', + 'Detached HEAD Commit requires explicit confirmation.', + ); + } + if ( + worktree.availability.kind !== 'available' || + worktree.canonicalPath === null || + worktree.freshness.kind !== 'fresh' || + worktree.status === null || + worktree.status.conflicted !== 0 || + worktree.status.inProgressOperation !== undefined || + worktree.status.staged === 0 || + worktree.index === null + ) { + return reject( + 'precondition_failed', + 'Commit requires staged content in an available Worktree with no Conflict or Git operation.', + ); + } + if (worktree.index.locked || worktree.gitLock.kind !== 'unlocked') { + return reject( + 'index_locked', + 'The Worktree Index is locked by another Git process.', + ); + } + if (options.runGit === undefined) { + return reject( + 'unsupported_state', + 'Commit is unavailable in this Repository Session.', + ); + } + try { + await Promise.all([ + options.runGit( + ['-C', worktree.canonicalPath, 'var', 'GIT_AUTHOR_IDENT'], + false, + undefined, + signal, + ), + options.runGit( + ['-C', worktree.canonicalPath, 'var', 'GIT_COMMITTER_IDENT'], + false, + undefined, + signal, + ), + ]); + } catch (error) { + if (signal.aborted) throw error; + return reject( + 'missing_identity', + 'Git author and committer identity must be configured before Commit.', + ); + } + const expectedIndexTree = decodeGitLine( + await options.runGit( + ['-C', worktree.canonicalPath, 'write-tree'], + false, + undefined, + signal, + ), + ); + commitIntent = { + summary: firstCommitLine(draft.text), + successfulObjectId: null, + successfulParentObjectIds: null, + }; + const finalInspection = await ( + options.inspectCommitTarget ?? + ((worktreePath, inspectionSignal) => + inspectCommitTarget( + worktreePath, + options.runGit!, + inspectionSignal, + )) + )(worktree.canonicalPath, signal); + if (finalInspection.indexLocked) { + return reject( + 'index_locked', + 'The Worktree Index is locked by another Git process.', + ); + } + if ( + finalInspection.commonGitDirectory !== + initialCommonGitDirectory || + finalInspection.worktreePath !== initialCanonicalPath || + finalInspection.headObjectId !== worktree.head.objectId || + finalInspection.indexTree !== expectedIndexTree + ) { + return reject( + 'stale', + 'Worktree identity, HEAD, or Index changed immediately before Commit.', + ); + } + let indexTransaction: CommitIndexTransaction; + try { + indexTransaction = await ( + options.beginCommitIndexTransaction ?? + beginCommitIndexTransaction + )(finalInspection.indexPath); + } catch (error) { + if (error instanceof CommitIndexLockExists) { + return reject( + 'index_locked', + 'The Worktree Index is locked by another Git process.', + ); + } + throw error; + } + const transactionIndexTree = decodeGitLine( + await options.runGit( + ['-C', worktree.canonicalPath, 'write-tree'], + false, + undefined, + signal, + undefined, + undefined, + indexTransaction.environment, + ), + ); + const transactionHeadObjectId = decodeGitLine( + await options.runGit( + [ + '-C', + worktree.canonicalPath, + 'rev-parse', + '--verify', + '-q', + 'HEAD', + ], + false, + 1, + signal, + ), + ); + if ( + transactionIndexTree !== expectedIndexTree || + (transactionHeadObjectId.length === 0 + ? null + : transactionHeadObjectId) !== worktree.head.objectId + ) { + await indexTransaction.cleanupKnownFailure(); + return reject( + 'stale', + 'HEAD or Index changed while acquiring the Commit transaction.', + ); + } + let commitOutput: Uint8Array; + try { + commitOutput = await options.runGit( + ['-C', worktree.canonicalPath, 'commit', '--file=-'], + true, + undefined, + signal, + undefined, + new TextEncoder().encode(draft.text), + indexTransaction.environment, + ); + } catch (error) { + if (signal.aborted) throw error; + if (!isKnownGitFailure(error)) throw error; + await indexTransaction.cleanupKnownFailure(); + return { + kind: 'failed_known' as const, + code: classifyCommitFailure(error), + }; + } + await indexTransaction.promote(); + const committedObjectAbbreviation = + readCommitObjectAbbreviation(commitOutput); + if (committedObjectAbbreviation === null) { + throw new Error( + 'Git returned success without an attributable Commit identity.', + ); + } + const identity = decodeCommitIdentity( + await options.runGit( + [ + '-C', + worktree.canonicalPath, + 'rev-list', + '--parents', + '-n', + '1', + `${committedObjectAbbreviation}^{commit}`, + ], + false, + undefined, + AbortSignal.timeout(10_000), + ), + ); + commitIntent.successfulObjectId = identity.objectId; + commitIntent.successfulParentObjectIds = identity.parentObjectIds; + return { + kind: 'attempted' as const, + successfulObjectId: commitIntent.successfulObjectId, + successfulParentObjectIds: commitIntent.successfulParentObjectIds, + }; + }, + async reconcile(context) { + const evidence = + context.execution.kind === 'returned' + ? context.execution.evidence + : undefined; + const reconciled = await observe(() => + delegate.requestRefresh(), + ).catch(() => undefined); + if ( + reconciled?.kind !== 'repository' || + reconciled.repository.refresh.kind !== 'fresh' + ) { + return unknownCommit(); + } + if (evidence?.kind === 'rejected') return evidence.result; + const worktree = reconciled.repository.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ); + if ( + reconciled.repository.repositoryId !== initialRepositoryId || + reconciled.repository.commonGitDirectory !== + initialCommonGitDirectory || + worktree === undefined || + worktree.generation !== initialGeneration || + worktree.canonicalPath !== initialCanonicalPath || + worktree.freshness.kind !== 'fresh' + ) { + return unknownCommit(); + } + const previousObjectId = initialHead.objectId; + const currentObjectId = worktree.head.objectId; + if (evidence?.kind === 'failed_known') { + return { + kind: 'failed_known', + code: evidence.code, + message: commitFailureMessage(evidence.code), + }; + } + const successfulObjectId = + evidence?.kind === 'attempted' + ? evidence.successfulObjectId + : commitIntent?.successfulObjectId; + const successfulParentObjectIds = + evidence?.kind === 'attempted' + ? evidence.successfulParentObjectIds + : commitIntent?.successfulParentObjectIds; + if ( + successfulObjectId !== null && + successfulObjectId !== undefined && + successfulParentObjectIds !== null && + successfulParentObjectIds !== undefined && + commitIntent !== undefined && + currentObjectId === successfulObjectId && + currentObjectId !== previousObjectId && + commitParentsMatch(successfulParentObjectIds, previousObjectId) && + headAttachmentMatches(worktree.head, initialHead) + ) { + const currentDraft = commitDrafts.get(command.worktreeId); + if (currentDraft?.revision === command.draftRevision) { + commitDrafts.set(command.worktreeId, { + revision: currentDraft.revision + 1, + text: '', + }); + } + return { + kind: 'succeeded', + result: { + kind: 'commit', + shortObjectId: currentObjectId.slice(0, 7), + summary: commitIntent.summary, + }, + }; + } + return unknownCommit(); + }, + }); + if (admission.kind === 'closed') { + throw new Error('The Repository Session is closed.'); + } + return { + operationId: + admission.kind === 'accepted' + ? admission.operation.operationId + : admission.result.operationId, + clientCommandId: request.clientCommandId, + disposition: 'accepted', + }; + } if (request.command.kind !== 'switch_branch') { throw new Error( 'This Repository Session does not support that command.', @@ -2070,6 +2529,267 @@ function isKnownGitFailure(error: unknown): boolean { ); } +function classifyCommitFailure(error: unknown): OperationFailureCode { + if (error instanceof Error && 'gitFailureCode' in error) { + if (error.gitFailureCode === 'hook_rejected') return 'hook_rejected'; + if (error.gitFailureCode === 'signing_failed') return 'signing_failed'; + } + return 'process_failed'; +} + +function commitFailureMessage(code: OperationFailureCode): string { + if (code === 'hook_rejected') { + return 'A configured Git hook rejected the Commit.'; + } + if (code === 'signing_failed') { + return 'Git could not sign the Commit with the configured signing setup.'; + } + return 'Git could not create the Commit.'; +} + +function unknownCommit() { + return { + kind: 'unknown_outcome' as const, + code: 'reconciliation_incomplete' as const, + message: 'The Commit outcome could not be reconciled to fresh Git state.', + recoveryAvailable: true as const, + }; +} + +function sameHead( + left: RepositorySnapshot['worktrees'][number]['head'], + right: RepositorySnapshot['worktrees'][number]['head'], +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function decodeGitLine(output: Uint8Array): string { + return new TextDecoder('utf-8', { fatal: true }) + .decode(output) + .replace(/\r?\n$/u, ''); +} + +function firstCommitLine(message: string): string { + return message.split(/\r?\n/u, 1)[0]?.trim().slice(0, 512) || 'Commit'; +} + +function readCommitObjectAbbreviation(output: Uint8Array): string | null { + const text = new TextDecoder('utf-8', { fatal: true }).decode(output); + const matches = [...text.matchAll(/^\[.+ ([0-9a-f]{4,64})\](?: |$)/gmu)]; + return matches.at(-1)?.[1] ?? null; +} + +function decodeCommitIdentity(output: Uint8Array): { + readonly objectId: string; + readonly parentObjectIds: readonly string[]; +} { + const fields = decodeGitLine(output).split(' '); + const objectId = fields[0]; + if ( + objectId === undefined || + !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(objectId) || + fields + .slice(1) + .some((field) => !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(field)) + ) { + throw new Error('Git returned an invalid Commit identity.'); + } + return { objectId, parentObjectIds: fields.slice(1) }; +} + +async function inspectCommitTarget( + worktreePath: AbsolutePath, + runGit: GitProcessRunner, + signal: AbortSignal, +): Promise { + const commonGitDirectory = await realpath( + decodeGitLine( + await runGit( + [ + '-C', + worktreePath, + 'rev-parse', + '--path-format=absolute', + '--git-common-dir', + ], + false, + undefined, + signal, + ), + ), + ); + const resolvedWorktreePath = await realpath( + decodeGitLine( + await runGit( + [ + '-C', + worktreePath, + 'rev-parse', + '--path-format=absolute', + '--show-toplevel', + ], + false, + undefined, + signal, + ), + ), + ); + const headObjectId = decodeGitLine( + await runGit( + ['-C', worktreePath, 'rev-parse', '--verify', '-q', 'HEAD'], + false, + 1, + signal, + ), + ); + const indexTree = decodeGitLine( + await runGit(['-C', worktreePath, 'write-tree'], false, undefined, signal), + ); + const indexPath = decodeGitLine( + await runGit( + [ + '-C', + worktreePath, + 'rev-parse', + '--path-format=absolute', + '--git-path', + 'index', + ], + false, + undefined, + signal, + ), + ); + return { + commonGitDirectory, + worktreePath: resolvedWorktreePath, + headObjectId: headObjectId.length === 0 ? null : headObjectId, + indexTree, + indexPath, + indexLocked: await pathIsAccessible(`${indexPath}.lock`), + }; +} + +async function beginCommitIndexTransaction( + indexPath: string, +): Promise { + const sentinelPath = `${indexPath}.lock`; + const privateIndexPath = `${indexPath}.codex-commit-${randomUUID()}`; + let sentinel; + try { + sentinel = await open(sentinelPath, 'wx', 0o600); + } catch (error) { + if (isFileExistsError(error)) throw new CommitIndexLockExists(); + throw error; + } + let sentinelIdentity: string; + try { + sentinelIdentity = fileIdentity(await sentinel.stat()); + } catch (error) { + await sentinel.close().catch(() => undefined); + throw error; + } + await sentinel.close(); + let privateIndex; + try { + privateIndex = await open(privateIndexPath, 'wx', 0o600); + const contents = await readFile(indexPath); + const metadata = await open(indexPath, 'r'); + try { + const { mode } = await metadata.stat(); + await privateIndex.chmod(mode & 0o777); + } finally { + await metadata.close(); + } + await privateIndex.writeFile(contents); + await privateIndex.sync(); + } catch (error) { + await privateIndex?.close().catch(() => undefined); + await unlink(privateIndexPath).catch(() => undefined); + const current = await stat(sentinelPath).catch(() => undefined); + if (current !== undefined && fileIdentity(current) === sentinelIdentity) { + await unlink(sentinelPath).catch(() => undefined); + } + throw error; + } + await privateIndex.close(); + let privateOwned = true; + let sentinelOwned = true; + const removeOwnedSentinel = async () => { + if (!sentinelOwned) return; + const current = await stat(sentinelPath).catch(() => undefined); + if (current === undefined || fileIdentity(current) !== sentinelIdentity) { + return; + } + await unlink(sentinelPath); + sentinelOwned = false; + }; + return { + environment: { GIT_INDEX_FILE: privateIndexPath }, + async promote() { + if (!privateOwned) { + throw new Error('The Commit Index transaction is closed.'); + } + await rename(privateIndexPath, indexPath); + privateOwned = false; + await removeOwnedSentinel(); + }, + async cleanupKnownFailure() { + if (privateOwned) { + await unlink(privateIndexPath).catch(() => undefined); + await unlink(`${privateIndexPath}.lock`).catch(() => undefined); + privateOwned = false; + } + await removeOwnedSentinel(); + }, + }; +} + +function fileIdentity(value: { + readonly birthtimeMs: number; + readonly dev: number; + readonly ino: number; +}): string { + return `${value.dev}:${value.ino}:${value.birthtimeMs}`; +} + +function isFileExistsError(error: unknown): error is NodeJS.ErrnoException { + return ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +async function pathIsAccessible(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function headAttachmentMatches( + current: RepositorySnapshot['worktrees'][number]['head'], + initial: RepositorySnapshot['worktrees'][number]['head'], +): boolean { + if (current.kind !== initial.kind) return false; + return ( + current.kind !== 'local_branch' || + (initial.kind === 'local_branch' && current.fullName === initial.fullName) + ); +} + +function commitParentsMatch( + actual: readonly string[], + expectedParentObjectId: string | null, +): boolean { + return expectedParentObjectId === null + ? actual.length === 0 + : actual.length === 1 && actual[0] === expectedParentObjectId; +} + function unknownFileMutation() { return { kind: 'unknown_outcome' as const, diff --git a/tests/integration/repository-commit.integration.test.ts b/tests/integration/repository-commit.integration.test.ts new file mode 100644 index 0000000..3761303 --- /dev/null +++ b/tests/integration/repository-commit.integration.test.ts @@ -0,0 +1,933 @@ +import { chmod, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { AbsolutePath, ClientCommandId } from '@codex-git/protocol'; +import { createRepositoryEngine } from '@codex-git/repository-engine'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + createTemporaryGitRepository, + type TemporaryGitRepository, +} from '../fixtures/temporary-git-repository.js'; + +const repositories: TemporaryGitRepository[] = []; +const externalPaths: string[] = []; + +afterEach(async () => { + await Promise.all( + repositories.splice(0).map((repository) => repository.dispose()), + ); + await Promise.all( + externalPaths + .splice(0) + .map((path) => rm(path, { force: true, recursive: true })), + ); +}); + +describe('Repository Commit', () => { + it('commits exactly staged content, preserves unstaged bytes, and clears only the successful Worktree draft', async () => { + const repository = await repositoryWithCommit(); + await repository.git('branch', 'linked-draft'); + const linkedPath = `${repository.path}-successful-draft-linked`; + externalPaths.push(linkedPath); + await repository.git( + 'worktree', + 'add', + '--quiet', + linkedPath, + 'linked-draft', + ); + await writeFile(join(repository.path, 'README.md'), 'staged version\n'); + await repository.git('add', 'README.md'); + await writeFile(join(repository.path, 'README.md'), 'unstaged version\n'); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const linked = opened.repository.worktrees.find( + ({ role }) => role === 'linked', + )!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Commit title\n\nCommit body' }, + }); + const linkedDraft = await session.updateDraft({ + worktreeId: linked.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Keep linked draft' }, + }); + + const receipt = await session.dispatch({ + clientCommandId: commandId(1), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'succeeded', + result: { kind: 'commit', summary: 'Commit title' }, + }); + expect((await repository.git('show', 'HEAD:README.md')).stdout).toBe( + 'staged version\n', + ); + expect(await readFile(join(repository.path, 'README.md'), 'utf8')).toBe( + 'unstaged version\n', + ); + await expect( + session.updateDraft({ kind: 'get', worktreeId: worktree.worktreeId }), + ).resolves.toMatchObject({ revision: draft.revision + 1, text: '' }); + await expect( + session.updateDraft({ kind: 'get', worktreeId: linked.worktreeId }), + ).resolves.toEqual(linkedDraft); + await session.close(); + }); + + it('supports the Initial Commit and preserves a draft after missing identity rejection', async () => { + const repository = await createTemporaryGitRepository(); + repositories.push(repository); + await repository.git('config', 'user.name', ''); + await repository.git('config', 'user.email', ''); + await writeFile(join(repository.path, 'initial.txt'), 'initial\n'); + await repository.git('add', 'initial.txt'); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Initial commit' }, + }); + + const rejectedReceipt = await session.dispatch({ + clientCommandId: commandId(2), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + expect( + await session.recoverOperation(rejectedReceipt.operationId), + ).toMatchObject({ kind: 'rejected', code: 'missing_identity' }); + await expect( + session.updateDraft({ kind: 'get', worktreeId: worktree.worktreeId }), + ).resolves.toEqual(draft); + await expect( + readFile(join(repository.path, '.git', 'index.lock')), + ).rejects.toThrow(); + + await repository.git('config', 'user.email', 'codex-git@example.invalid'); + await repository.git('config', 'user.name', 'Codex Git'); + const refreshed = await session.requestRefresh(); + if (refreshed.kind !== 'repository') throw new Error('Expected Repository'); + const current = refreshed.repository.worktrees[0]!; + const successReceipt = await session.dispatch({ + clientCommandId: commandId(3), + command: { + kind: 'commit', + worktreeId: current.worktreeId, + expectedWorktreeRevision: current.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + + expect( + await session.recoverOperation(successReceipt.operationId), + ).toMatchObject({ kind: 'succeeded', result: { kind: 'commit' } }); + expect( + (await repository.git('log', '-1', '--format=%s')).stdout.trim(), + ).toBe('Initial commit'); + await session.close(); + }); + + it('requires explicit confirmation before committing on Detached HEAD', async () => { + const repository = await repositoryWithCommit(); + await repository.git('switch', '--detach', '--quiet'); + await writeFile(join(repository.path, 'README.md'), 'detached change\n'); + await repository.git('add', 'README.md'); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Detached commit' }, + }); + const rejected = await session.dispatch({ + clientCommandId: commandId(4), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + + expect(await session.recoverOperation(rejected.operationId)).toMatchObject({ + kind: 'rejected', + code: 'precondition_failed', + }); + expect((await repository.git('rev-parse', 'HEAD')).stdout.trim()).toBe( + worktree.head.kind === 'detached' ? worktree.head.objectId : '', + ); + const confirmed = await session.dispatch({ + clientCommandId: commandId(16), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: true, + }, + }); + expect(await session.recoverOperation(confirmed.operationId)).toMatchObject( + { + kind: 'succeeded', + result: { kind: 'commit', summary: 'Detached commit' }, + }, + ); + expect((await repository.git('rev-parse', 'HEAD')).stdout.trim()).not.toBe( + worktree.head.kind === 'detached' ? worktree.head.objectId : '', + ); + await session.close(); + }); + + it('truthfully reports a silent hook rejection as an unclassified process failure', async () => { + const repository = await repositoryWithCommit(); + await writeFile(join(repository.path, 'README.md'), 'hooked\n'); + await repository.git('add', 'README.md'); + const indexSignalPath = `${repository.path}-silent-hook-index`; + externalPaths.push(indexSignalPath); + const hook = join(repository.path, '.git', 'hooks', 'pre-commit'); + await writeFile( + hook, + `#!/bin/sh\nprintf "%s" "$GIT_INDEX_FILE" > "${indexSignalPath}"\ncp "$GIT_INDEX_FILE" "$GIT_INDEX_FILE.replacement"\nmv "$GIT_INDEX_FILE.replacement" "$GIT_INDEX_FILE"\nexit 1\n`, + ); + await chmod(hook, 0o700); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Rejected by hook' }, + }); + + const receipt = await session.dispatch({ + clientCommandId: commandId(5), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + + expect(await session.recoverOperation(receipt.operationId)).toMatchObject({ + kind: 'failed_known', + code: 'process_failed', + message: 'Git could not create the Commit.', + }); + await expect( + session.updateDraft({ kind: 'get', worktreeId: worktree.worktreeId }), + ).resolves.toEqual(draft); + await expect( + readFile(await readFile(indexSignalPath, 'utf8')), + ).rejects.toThrow(); + await expect( + readFile(join(repository.path, '.git', 'index.lock')), + ).rejects.toThrow(); + await session.close(); + }); + + it('classifies an observed hook diagnostic and preserves the draft', async () => { + const repository = await repositoryWithCommit(); + await writeFile(join(repository.path, 'README.md'), 'hooked\n'); + await repository.git('add', 'README.md'); + const hook = join(repository.path, '.git', 'hooks', 'pre-commit'); + await writeFile( + hook, + '#!/bin/sh\necho "pre-commit hook failed" >&2\nexit 1\n', + ); + await chmod(hook, 0o700); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Observed hook rejection' }, + }); + const receipt = await session.dispatch({ + clientCommandId: commandId(17), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + + expect(await session.recoverOperation(receipt.operationId)).toMatchObject({ + kind: 'failed_known', + code: 'hook_rejected', + }); + await expect( + session.updateDraft({ kind: 'get', worktreeId: worktree.worktreeId }), + ).resolves.toEqual(draft); + await session.close(); + }); + + it('accepts the exact successful Commit OID when hooks modify the Index and message', async () => { + const repository = await repositoryWithCommit(); + await writeFile(join(repository.path, 'README.md'), 'hook mutation\n'); + await repository.git('add', 'README.md'); + const preCommit = join(repository.path, '.git', 'hooks', 'pre-commit'); + const commitMessage = join(repository.path, '.git', 'hooks', 'commit-msg'); + await writeFile( + preCommit, + '#!/bin/sh\nprintf "hook staged\\n" > hook-added.txt\ngit add hook-added.txt\n', + ); + await writeFile( + commitMessage, + '#!/bin/sh\nprintf "\\nHook-added trailer\\n" >> "$1"\n', + ); + await chmod(preCommit, 0o700); + await chmod(commitMessage, 0o700); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Hook mutation' }, + }); + const receipt = await session.dispatch({ + clientCommandId: commandId(18), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + + expect(await session.recoverOperation(receipt.operationId)).toMatchObject({ + kind: 'succeeded', + result: { kind: 'commit' }, + }); + expect((await repository.git('show', 'HEAD:hook-added.txt')).stdout).toBe( + 'hook staged\n', + ); + expect((await repository.git('log', '-1', '--format=%B')).stdout).toContain( + 'Hook-added trailer', + ); + await session.close(); + }); + + it('rejects stale Index evidence and an unresolved external Index lock before Commit', async () => { + const repository = await repositoryWithCommit(); + await writeFile(join(repository.path, 'README.md'), 'reviewed\n'); + await repository.git('add', 'README.md'); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Stale commit' }, + }); + await writeFile(join(repository.path, 'extra.txt'), 'external\n'); + await repository.git('add', 'extra.txt'); + const staleReceipt = await session.dispatch({ + clientCommandId: commandId(6), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + expect( + await session.recoverOperation(staleReceipt.operationId), + ).toMatchObject({ kind: 'rejected', code: 'stale' }); + + const refreshed = await session.requestRefresh(); + if (refreshed.kind !== 'repository') throw new Error('Expected Repository'); + const current = refreshed.repository.worktrees[0]!; + await writeFile( + join(repository.path, '.git', 'index.lock'), + 'external lock', + ); + const lockedReceipt = await session.dispatch({ + clientCommandId: commandId(7), + command: { + kind: 'commit', + worktreeId: current.worktreeId, + expectedWorktreeRevision: current.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + expect( + await session.recoverOperation(lockedReceipt.operationId), + ).toMatchObject({ kind: 'rejected', code: 'index_locked' }); + await session.close(); + }); + + it('keeps drafts through Refresh and Branch changes and clears only the explicit Worktree', async () => { + const repository = await repositoryWithCommit(); + await repository.git('branch', 'other'); + const linkedPath = `${repository.path}-draft-linked`; + externalPaths.push(linkedPath); + await repository.git('worktree', 'add', '--quiet', linkedPath, 'other'); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const [main, linked] = opened.repository.worktrees; + if (main === undefined || linked === undefined) { + throw new Error('Expected two Worktrees'); + } + const mainDraft = await session.updateDraft({ + worktreeId: main.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Main draft' }, + }); + const linkedDraft = await session.updateDraft({ + worktreeId: linked.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Linked draft\n\nBody' }, + }); + + await session.requestRefresh(); + await repository.git('switch', '--quiet', '-c', 'changed-branch'); + await session.requestRefresh(); + + await expect( + session.updateDraft({ kind: 'get', worktreeId: main.worktreeId }), + ).resolves.toEqual(mainDraft); + await expect( + session.updateDraft({ kind: 'get', worktreeId: linked.worktreeId }), + ).resolves.toEqual(linkedDraft); + await expect( + session.updateDraft({ + worktreeId: main.worktreeId, + expectedRevision: mainDraft.revision, + update: { kind: 'clear' }, + }), + ).resolves.toMatchObject({ text: '' }); + await expect( + session.updateDraft({ kind: 'get', worktreeId: linked.worktreeId }), + ).resolves.toEqual(linkedDraft); + await session.close(); + }); + + it('runs Commits concurrently in different Worktrees without crossing HEAD or Index', async () => { + const repository = await repositoryWithCommit(); + const mainBranch = ( + await repository.git('branch', '--show-current') + ).stdout.trim(); + await repository.git('branch', 'linked'); + const linkedPath = `${repository.path}-concurrent-linked`; + externalPaths.push(linkedPath); + await repository.git('worktree', 'add', '--quiet', linkedPath, 'linked'); + await writeFile(join(repository.path, 'main.txt'), 'main\n'); + await repository.git('add', 'main.txt'); + await writeFile(join(linkedPath, 'linked.txt'), 'linked\n'); + await repository.git('-C', linkedPath, 'add', 'linked.txt'); + const signalPath = `${repository.path}-commit-signal`; + const releasePath = `${repository.path}-commit-release`; + externalPaths.push(signalPath, releasePath); + const hook = join(repository.path, '.git', 'hooks', 'pre-commit'); + await writeFile( + hook, + `#!/bin/sh\nif [ -f main.txt ]; then printf signal > "${signalPath}"; while [ ! -f "${releasePath}" ]; do sleep 0.01; done; fi\n`, + ); + await chmod(hook, 0o700); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const main = opened.repository.worktrees.find( + ({ role }) => role === 'main', + )!; + const linked = opened.repository.worktrees.find( + ({ role }) => role === 'linked', + )!; + const mainDraft = await session.updateDraft({ + worktreeId: main.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Main concurrent Commit' }, + }); + const linkedDraft = await session.updateDraft({ + worktreeId: linked.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Linked concurrent Commit' }, + }); + const mainReceipt = await session.dispatch({ + clientCommandId: commandId(8), + command: { + kind: 'commit', + worktreeId: main.worktreeId, + expectedWorktreeRevision: main.worktreeRevision, + draftRevision: mainDraft.revision, + confirmDetachedHead: false, + }, + }); + await waitForPath(signalPath); + const linkedReceipt = await session.dispatch({ + clientCommandId: commandId(9), + command: { + kind: 'commit', + worktreeId: linked.worktreeId, + expectedWorktreeRevision: linked.worktreeRevision, + draftRevision: linkedDraft.revision, + confirmDetachedHead: false, + }, + }); + + const linkedResult = await session.recoverOperation( + linkedReceipt.operationId, + ); + expect(linkedResult).toMatchObject({ kind: 'succeeded' }); + await writeFile(releasePath, 'continue'); + expect( + await session.recoverOperation(mainReceipt.operationId), + ).toMatchObject({ + kind: 'succeeded', + }); + expect( + (await repository.git('show', `${mainBranch}:main.txt`)).stdout, + ).toBe('main\n'); + expect((await repository.git('show', 'linked:linked.txt')).stdout).toBe( + 'linked\n', + ); + await session.close(); + }); + + it('owns the native Index lock transaction and rejects post-launch external staging', async () => { + const repository = await repositoryWithCommit(); + await writeFile(join(repository.path, 'README.md'), 'native lock\n'); + await writeFile(join(repository.path, 'later.txt'), 'later\n'); + await repository.git('add', 'README.md'); + const signalPath = `${repository.path}-index-lock-signal`; + const releasePath = `${repository.path}-index-lock-release`; + externalPaths.push(signalPath, releasePath); + const hook = join(repository.path, '.git', 'hooks', 'pre-commit'); + await writeFile( + hook, + `#!/bin/sh\nprintf "%s" "$GIT_INDEX_FILE" > "${signalPath}"\nwhile [ ! -f "${releasePath}" ]; do sleep 0.01; done\n`, + ); + await chmod(hook, 0o700); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Native Index lock' }, + }); + const receipt = await session.dispatch({ + clientCommandId: commandId(19), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + await waitForPath(signalPath); + + const privateIndexPath = await readFile(signalPath, 'utf8'); + expect(privateIndexPath).toMatch(/index\.codex-commit-[0-9a-f-]+$/u); + expect(privateIndexPath).not.toBe( + join(repository.path, '.git', 'index.lock'), + ); + await expect( + readFile(join(repository.path, '.git', 'index.lock'), 'utf8'), + ).resolves.toBe(''); + await expect(repository.git('add', 'later.txt')).rejects.toThrow(); + await writeFile(releasePath, 'continue'); + expect(await session.recoverOperation(receipt.operationId)).toMatchObject({ + kind: 'succeeded', + }); + await expect(readFile(privateIndexPath)).rejects.toThrow(); + await expect( + readFile(join(repository.path, '.git', 'index.lock')), + ).rejects.toThrow(); + await expect(repository.git('show', 'HEAD:later.txt')).rejects.toThrow(); + expect( + (await repository.git('status', '--short', '--', 'later.txt')).stdout, + ).toBe('?? later.txt\n'); + await session.close(); + }); + + it('lets native Git expected-old ref CAS reject a post-launch HEAD mutation', async () => { + const repository = await repositoryWithCommit(); + const attachedRef = ( + await repository.git('symbolic-ref', 'HEAD') + ).stdout.trim(); + await writeFile(join(repository.path, 'README.md'), 'ref race\n'); + await repository.git('add', 'README.md'); + const parent = (await repository.git('rev-parse', 'HEAD')).stdout.trim(); + const tree = (await repository.git('write-tree')).stdout.trim(); + const signalPath = `${repository.path}-ref-cas-signal`; + const releasePath = `${repository.path}-ref-cas-release`; + externalPaths.push(signalPath, releasePath); + const hook = join(repository.path, '.git', 'hooks', 'pre-commit'); + await writeFile( + hook, + `#!/bin/sh\nprintf signal > "${signalPath}"\nwhile [ ! -f "${releasePath}" ]; do sleep 0.01; done\n`, + ); + await chmod(hook, 0o700); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Ref CAS race' }, + }); + const receipt = await session.dispatch({ + clientCommandId: commandId(20), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + await waitForPath(signalPath); + const externalCommit = ( + await repository.git('commit-tree', tree, '-p', parent, '-m', 'External') + ).stdout.trim(); + await repository.git('update-ref', attachedRef, externalCommit, parent); + await writeFile(releasePath, 'continue'); + + expect(await session.recoverOperation(receipt.operationId)).toMatchObject({ + kind: 'failed_known', + code: 'process_failed', + }); + expect((await repository.git('rev-parse', 'HEAD')).stdout.trim()).toBe( + externalCommit, + ); + await expect( + session.updateDraft({ kind: 'get', worktreeId: worktree.worktreeId }), + ).resolves.toEqual(draft); + await session.close(); + }); + + it('classifies configured signing failure without exposing raw diagnostics', async () => { + const repository = await repositoryWithCommit(); + await writeFile(join(repository.path, 'README.md'), 'signed\n'); + await repository.git('add', 'README.md'); + const signer = `${repository.path}-failing-signer`; + externalPaths.push(signer); + await writeFile( + signer, + '#!/bin/sh\necho "signing failed with secret-token" >&2\nexit 1\n', + ); + await chmod(signer, 0o700); + await repository.git('config', 'commit.gpgsign', 'true'); + await repository.git('config', 'gpg.program', signer); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Signed Commit' }, + }); + const receipt = await session.dispatch({ + clientCommandId: commandId(10), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + + const result = await session.recoverOperation(receipt.operationId); + expect(result).toMatchObject({ + kind: 'failed_known', + code: 'signing_failed', + message: + 'Git could not sign the Commit with the configured signing setup.', + }); + expect(JSON.stringify(result)).not.toContain('secret-token'); + await session.close(); + }); + + it('blocks Commit with an empty Index or an In-progress Git Operation', async () => { + const repository = await repositoryWithCommit(); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Blocked Commit' }, + }); + const emptyReceipt = await session.dispatch({ + clientCommandId: commandId(11), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + expect( + await session.recoverOperation(emptyReceipt.operationId), + ).toMatchObject({ kind: 'rejected', code: 'precondition_failed' }); + + await writeFile( + join(repository.path, 'README.md'), + 'staged while bisecting\n', + ); + await repository.git('add', 'README.md'); + await writeFile( + join(repository.path, '.git', 'BISECT_LOG'), + 'in progress\n', + ); + const refreshed = await session.requestRefresh(); + if (refreshed.kind !== 'repository') throw new Error('Expected Repository'); + const inProgress = refreshed.repository.worktrees[0]!; + const blockedReceipt = await session.dispatch({ + clientCommandId: commandId(12), + command: { + kind: 'commit', + worktreeId: inProgress.worktreeId, + expectedWorktreeRevision: inProgress.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + expect( + await session.recoverOperation(blockedReceipt.operationId), + ).toMatchObject({ kind: 'rejected', code: 'precondition_failed' }); + await session.close(); + }); + + it('blocks Commit while the Index contains Conflict entries', async () => { + const repository = await repositoryWithCommit(); + await repository.git('switch', '--quiet', '-c', 'conflicting'); + await writeFile(join(repository.path, 'README.md'), 'other\n'); + await repository.git('commit', '--quiet', '-am', 'Other'); + await repository.git('switch', '--quiet', '-'); + await writeFile(join(repository.path, 'README.md'), 'current\n'); + await repository.git('commit', '--quiet', '-am', 'Current'); + await expect(repository.git('merge', 'conflicting')).rejects.toThrow(); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Conflict Commit' }, + }); + const receipt = await session.dispatch({ + clientCommandId: commandId(15), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + + expect(await session.recoverOperation(receipt.operationId)).toMatchObject({ + kind: 'rejected', + code: 'precondition_failed', + }); + await session.close(); + }); + + it('reports a timed-out Commit as Unknown Outcome and blocks a duplicate retry', async () => { + const repository = await repositoryWithCommit(); + await writeFile(join(repository.path, 'README.md'), 'timeout\n'); + await repository.git('add', 'README.md'); + const signalPath = `${repository.path}-timeout-signal`; + externalPaths.push(signalPath); + const hook = join(repository.path, '.git', 'hooks', 'pre-commit'); + await writeFile( + hook, + `#!/bin/sh\nprintf signal > "${signalPath}"\nwhile true; do sleep 0.05; done\n`, + ); + await chmod(hook, 0o700); + const session = await createRepositoryEngine({ + operationTimeoutMilliseconds: 75, + }).open(repository.path as AbsolutePath); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Timed out Commit' }, + }); + const receipt = await session.dispatch({ + clientCommandId: commandId(13), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + expect(await session.recoverOperation(receipt.operationId)).toMatchObject({ + kind: 'unknown_outcome', + recoveryAvailable: true, + }); + await expect( + session.updateDraft({ kind: 'get', worktreeId: worktree.worktreeId }), + ).resolves.toEqual(draft); + const retry = await session.dispatch({ + clientCommandId: commandId(14), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + expect(await session.recoverOperation(retry.operationId)).toMatchObject({ + kind: 'rejected', + code: 'busy', + }); + await session.close(); + }); + + it('cancels a running Git process through the Product Command and preserves Unknown recovery', async () => { + const repository = await repositoryWithCommit(); + await writeFile(join(repository.path, 'README.md'), 'cancelled\n'); + await repository.git('add', 'README.md'); + const signalPath = `${repository.path}-cancel-signal`; + externalPaths.push(signalPath); + const hook = join(repository.path, '.git', 'hooks', 'pre-commit'); + await writeFile( + hook, + `#!/bin/sh\nprintf signal > "${signalPath}"\nwhile true; do sleep 0.05; done\n`, + ); + await chmod(hook, 0o700); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + const draft = await session.updateDraft({ + worktreeId: worktree.worktreeId, + expectedRevision: 0, + update: { kind: 'set', text: 'Cancelled Commit' }, + }); + const receipt = await session.dispatch({ + clientCommandId: commandId(21), + command: { + kind: 'commit', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + draftRevision: draft.revision, + confirmDetachedHead: false, + }, + }); + await waitForPath(signalPath); + const cancellation = await session.dispatch({ + clientCommandId: commandId(22), + command: { + kind: 'cancel_operation', + operationId: receipt.operationId, + }, + }); + + expect( + await session.recoverOperation(cancellation.operationId), + ).toMatchObject({ kind: 'unknown_outcome', recoveryAvailable: true }); + await expect( + session.updateDraft({ kind: 'get', worktreeId: worktree.worktreeId }), + ).resolves.toEqual(draft); + await session.close(); + }); +}); + +async function repositoryWithCommit() { + const repository = await createTemporaryGitRepository(); + repositories.push(repository); + await repository.git('config', 'user.email', 'codex-git@example.invalid'); + await repository.git('config', 'user.name', 'Codex Git'); + await writeFile(join(repository.path, 'README.md'), 'initial\n'); + await repository.git('add', 'README.md'); + await repository.git('commit', '--quiet', '-m', 'Initial fixture'); + return repository; +} + +function commandId(index: number): ClientCommandId { + return `command_${index.toString(16).padStart(32, '0')}` as ClientCommandId; +} + +async function waitForPath(path: string) { + await expect + .poll(() => readFile(path, 'utf8').catch(() => ''), { timeout: 5_000 }) + .not.toBe(''); +}