diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 8c42fdf..10adf89 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -13,6 +13,9 @@ const loadingStore = createRepositoryStore({ kind: 'unavailable', message: 'No Repository is loaded.', }), + mutateFiles: async () => { + throw new Error('File mutations are 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/ChangeGroups.tsx b/apps/ui/src/ChangeGroups.tsx index 2c00cb7..9aaa8ac 100644 --- a/apps/ui/src/ChangeGroups.tsx +++ b/apps/ui/src/ChangeGroups.tsx @@ -13,10 +13,15 @@ export function ChangeGroups({ worktree, selectedFileId, onSelect, + onMutate, }: { readonly worktree: WorktreeOverviewSnapshot; readonly selectedFileId: FileId | null; readonly onSelect: (fileId: FileId) => void; + readonly onMutate: ( + kind: 'stage' | 'unstage', + fileIds: readonly FileId[], + ) => void; }) { if (worktree.changes.length === 0) { return

No Changed Files in this Worktree.

; @@ -33,6 +38,20 @@ export function ChangeGroups({

{group.label} {changes.length}

+ {group.kind === 'conflict' ? null : ( + + )} diff --git a/apps/ui/src/RepositoryOverview.interactions.test.tsx b/apps/ui/src/RepositoryOverview.interactions.test.tsx index f804c6c..d7215b7 100644 --- a/apps/ui/src/RepositoryOverview.interactions.test.tsx +++ b/apps/ui/src/RepositoryOverview.interactions.test.tsx @@ -56,6 +56,48 @@ describe('Repository overview interactions', () => { expect(container.textContent).toContain('Unified'); }); + it('offers file and group Stage and Unstage actions', async () => { + const fixture = createOverviewFixture('changed-worktree'); + const mutations: unknown[] = []; + const store = createRepositoryStore({ + ...fixture.source, + async mutateFiles(request: unknown) { + mutations.push(request); + return { + kind: 'succeeded' as const, + operationId: operationIdSchema.parse( + 'operation_00000000000000000000000000000002', + ), + result: { kind: 'files' as const, affectedCount: 2 }, + }; + }, + }); + act(() => root.render()); + + expect(button('Unstage README.md')).toBeDefined(); + expect(button('Stage src/app.ts')).toBeDefined(); + expect(button('Stage notes.txt')).toBeDefined(); + expect(button('Unstage all Staged Changes')).toBeDefined(); + expect(button('Stage all Changes')).toBeDefined(); + expect(button('Stage all Untracked Files')).toBeDefined(); + + await act(async () => button('Stage all Changes').click()); + + const source = fixture.source.getSnapshot(); + if (source.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = source.snapshot.worktrees[0]!; + expect(mutations).toEqual([ + { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: worktree.changes + .filter(({ kind }) => kind === 'change') + .map(({ fileId }) => fileId), + }, + ]); + }); + it('shows truthful metadata when Diff content cannot be rendered', async () => { const fixture = createOverviewFixture('changed-worktree'); const store = createRepositoryStore({ diff --git a/apps/ui/src/RepositoryOverview.tsx b/apps/ui/src/RepositoryOverview.tsx index 1a5ff2e..a42a2e9 100644 --- a/apps/ui/src/RepositoryOverview.tsx +++ b/apps/ui/src/RepositoryOverview.tsx @@ -491,7 +491,31 @@ export function RepositoryOverview({ worktree={selected} selectedFileId={state.selectedFileId} onSelect={(fileId) => store.selectFile(fileId)} + onMutate={(kind, fileIds) => store.mutateFiles(kind, fileIds)} /> + {state.fileMutationResult === null ? null : ( +
+

File operation result

+ {'message' in state.fileMutationResult ? ( +

{state.fileMutationResult.message}

+ ) : ( +

Changed Files updated.

+ )} + {'effects' in state.fileMutationResult && + state.fileMutationResult.effects !== undefined ? ( + + ) : null} +
+ )}

Diff

diff --git a/apps/ui/src/overview-fixtures.ts b/apps/ui/src/overview-fixtures.ts index 96b10f9..4c3a2c2 100644 --- a/apps/ui/src/overview-fixtures.ts +++ b/apps/ui/src/overview-fixtures.ts @@ -103,6 +103,9 @@ function createMutableFixture( message: 'Native actions are not installed in this fixture.', }; }, + async mutateFiles() { + throw new Error('File mutations are not configured for this fixture.'); + }, async searchBranches() { return { refsRevision: 0, candidates: [] }; }, diff --git a/apps/ui/src/protocol-repository-source.ts b/apps/ui/src/protocol-repository-source.ts index 76399a5..37c77b5 100644 --- a/apps/ui/src/protocol-repository-source.ts +++ b/apps/ui/src/protocol-repository-source.ts @@ -234,6 +234,27 @@ export function createProtocolRepositorySource( if (!response.ok) throw new Error('Native action request failed.'); return nativeActionResultSchema.parse(await response.json()); }, + async mutateFiles(request) { + const command = { ...request } satisfies ProductCommand; + const receipt = await submitCommand( + fetcher, + options.sessionUrl, + commandEnvelopeSchema.parse({ + clientCommandId: createClientCommandId(), + command, + }), + ); + if (!operationRecoveryAvailable) { + throw new Error('File mutation recovery is unavailable.'); + } + const result = await recoverOperation( + fetcher, + options.sessionUrl, + receipt.operationId, + ); + await requestSnapshot(); + return result; + }, async searchBranches(worktreeId, query) { const response = await protocolPost( fetcher, diff --git a/apps/ui/src/repository-overview-model.ts b/apps/ui/src/repository-overview-model.ts index 11ed3e4..cc99497 100644 --- a/apps/ui/src/repository-overview-model.ts +++ b/apps/ui/src/repository-overview-model.ts @@ -105,6 +105,12 @@ export interface RepositoryOverviewSource { requestNativeAction( request: NativeActionRequest, ): Promise; + mutateFiles(request: { + readonly kind: 'stage' | 'unstage'; + readonly worktreeId: ProtocolWorktree['worktreeId']; + readonly expectedWorktreeRevision: number; + readonly fileIds: readonly FileId[]; + }): 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 13d9be5..ac8b87c 100644 --- a/apps/ui/src/repository-store.test.ts +++ b/apps/ui/src/repository-store.test.ts @@ -1,3 +1,4 @@ +import { fileIdSchema, operationIdSchema } from '@codex-git/protocol'; import { describe, expect, it, vi } from 'vitest'; import { createOverviewFixture } from './overview-fixtures.js'; @@ -79,4 +80,57 @@ describe('RepositoryStore lifecycle', () => { ); } }); + + it('follows a successful file mutation into its new Change Group', async () => { + const fixture = createOverviewFixture('changed-worktree'); + const before = fixture.source.getSnapshot(); + if (before.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = before.snapshot.worktrees[0]!; + const changed = worktree.changes.find(({ kind }) => kind === 'change')!; + const stagedFileId = fileIdSchema.parse( + 'file_0000000000000000000000000000000a', + ); + const source: RepositoryOverviewSource = { + ...fixture.source, + async mutateFiles() { + fixture.publish({ + kind: 'repository', + snapshot: { + ...before.snapshot, + repositoryRevision: before.snapshot.repositoryRevision + 1, + worktrees: [ + { + ...worktree, + worktreeRevision: worktree.worktreeRevision + 1, + changes: worktree.changes.map((change) => + change.fileId === changed.fileId + ? { + ...change, + fileId: stagedFileId, + kind: 'staged_change' as const, + baseline: 'head_to_index' as const, + } + : change, + ), + }, + ], + }, + }); + return { + kind: 'succeeded', + operationId: operationIdSchema.parse( + 'operation_00000000000000000000000000000003', + ), + result: { kind: 'files', affectedCount: 1 }, + }; + }, + }; + const store = createRepositoryStore(source); + store.selectFile(changed.fileId); + + store.mutateFiles('stage', [changed.fileId]); + await Promise.resolve(); + + expect(store.getSnapshot().selectedFileId).toBe(stagedFileId); + }); }); diff --git a/apps/ui/src/repository-store.ts b/apps/ui/src/repository-store.ts index 21291dc..66aba23 100644 --- a/apps/ui/src/repository-store.ts +++ b/apps/ui/src/repository-store.ts @@ -4,6 +4,7 @@ import type { FileId, NativeActionRequest, NativeActionResult, + OperationResult, RefId, WorktreeId, } from '@codex-git/protocol'; @@ -52,6 +53,7 @@ export interface RepositoryStoreSnapshot { readonly selectionNotice: string | null; readonly focusRecoveryRevision: number; readonly branchPicker: BranchPickerState; + readonly fileMutationResult: OperationResult | null; } export interface RepositoryStore { @@ -70,6 +72,7 @@ export interface RepositoryStore { requestNativeAction( request: NativeActionRequest, ): Promise; + mutateFiles(kind: 'stage' | 'unstage', fileIds: readonly FileId[]): void; openBranchPicker(): void; closeBranchPicker(): void; setBranchQuery(query: string): void; @@ -94,6 +97,10 @@ export function createRepositoryStore( let focusRecoveryRevision = 0; let branchPicker: BranchPickerState = { kind: 'closed' }; let branchRequestGeneration = 0; + let fileMutationResult: OperationResult | null = null; + let fileFollow: + | { readonly displayPath: string; readonly kind: 'stage' | 'unstage' } + | undefined; let storeSnapshot = buildSnapshot(); let disposed = false; @@ -134,10 +141,22 @@ export function createRepositoryStore( selectedFileId !== null && !selected.changes.some(({ fileId }) => fileId === selectedFileId) ) { - selectedFileId = null; + const followed = + fileFollow === undefined + ? undefined + : selected.changes.find( + ({ displayPath, kind }) => + displayPath === fileFollow?.displayPath && + (fileFollow.kind === 'stage' + ? kind === 'staged_change' + : kind === 'change' || kind === 'untracked'), + ); + selectedFileId = followed?.fileId ?? null; clearDiff(); - selectionNotice = - 'Changed Files were refreshed; the previous file selection was cleared.'; + selectionNotice = followed + ? `${followed.displayPath} moved to its new Change Group.` + : 'Changed Files were refreshed; the previous file selection was cleared.'; + fileFollow = undefined; } else { selectionNotice = null; } @@ -239,6 +258,40 @@ export function createRepositoryStore( message: 'The Repository view is no longer active.', }) : source.requestNativeAction(request), + mutateFiles(kind, fileIds) { + if (disposed || fileIds.length === 0 || selectedWorktreeId === null) { + return; + } + const worktree = findWorktree(sourceState, selectedWorktreeId); + if (worktree === null) return; + const selectedChange = worktree.changes.find( + ({ fileId }) => fileId === selectedFileId && fileIds.includes(fileId), + ); + fileFollow = + selectedChange === undefined + ? undefined + : { displayPath: selectedChange.displayPath, kind }; + void source + .mutateFiles({ + kind, + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds, + }) + .then((result) => { + if (disposed) return; + fileMutationResult = result; + emit(); + }) + .catch(() => { + if (disposed) return; + selectionNotice = 'The file mutation could not be submitted.'; + emit(); + }) + .finally(() => { + fileFollow = undefined; + }); + }, openBranchPicker() { if (disposed || selectedWorktreeId === null) return; void loadBranches(''); @@ -365,6 +418,7 @@ export function createRepositoryStore( selectionNotice, focusRecoveryRevision, branchPicker, + fileMutationResult, }; } diff --git a/packages/protocol/src/operations.ts b/packages/protocol/src/operations.ts index 7813de1..881a5a7 100644 --- a/packages/protocol/src/operations.ts +++ b/packages/protocol/src/operations.ts @@ -52,7 +52,10 @@ export const operationRecoveryRequestSchema = z.strictObject({ const operationFailureEffectSchema = z.strictObject({ label: z.string().min(1).max(256), kind: z.literal('failed_known'), - code: operationFailureCodeSchema, + code: z.union([ + operationFailureCodeSchema, + z.enum(['index_locked', 'precondition_failed', 'stale']), + ]), message: messageSchema, }); diff --git a/packages/repository-engine/src/file-mutation-inspection.ts b/packages/repository-engine/src/file-mutation-inspection.ts new file mode 100644 index 0000000..8026f62 --- /dev/null +++ b/packages/repository-engine/src/file-mutation-inspection.ts @@ -0,0 +1,242 @@ +import { access, realpath, stat } from 'node:fs/promises'; +import type { Stats } from 'node:fs'; + +import type { RepositorySnapshot } from './repository-publication.js'; +import { fingerprintChangedFileTarget } from './repository-observation.js'; +import { parseWorktreeListPorcelain } from './worktree-porcelain.js'; + +export type FileMutationTarget = + RepositorySnapshot['worktrees'][number]['changes'][number]; + +export interface FileMutationInspection { + readonly commonGitDirectory: string; + readonly worktreePath: string; + readonly topologyEvidence: string; + readonly blockedBy: 'git_lock' | 'index_lock' | 'operation' | null; + readonly targetFingerprints: readonly string[]; +} + +export type FileMutationInspector = ( + worktree: RepositorySnapshot['worktrees'][number], + targets: readonly FileMutationTarget[], + signal: AbortSignal, +) => Promise; + +type GitRunner = ( + args: readonly string[], + allowLargeOutput: boolean, + acceptedEmptyExitCode?: 1, + signal?: AbortSignal, + maximumOutputBytes?: number, + input?: Uint8Array, +) => Promise; + +const operationMarkers = [ + 'rebase-merge', + 'rebase-apply', + 'MERGE_HEAD', + 'CHERRY_PICK_HEAD', + 'REVERT_HEAD', + 'BISECT_LOG', +] as const; + +export function createFileMutationInspector( + runGit: GitRunner, +): FileMutationInspector { + return async (worktree, targets, signal) => { + if (worktree.canonicalPath === null) { + throw new Error('Cannot inspect an unavailable Worktree.'); + } + const path = worktree.canonicalPath; + const paths = targets.flatMap((target) => + target.previousPathBytes === null + ? [target.pathBytes] + : [target.pathBytes, target.previousPathBytes], + ); + const [identityOutput, gitPathsOutput, registrationsOutput, headOutput] = + await Promise.all([ + runGit( + [ + '-C', + path, + 'rev-parse', + '--path-format=absolute', + '--git-common-dir', + '--show-toplevel', + '--git-dir', + ], + false, + undefined, + signal, + ), + runGit( + [ + '-C', + path, + 'rev-parse', + '--path-format=absolute', + '--git-path', + 'index', + ...operationMarkers.flatMap((marker) => ['--git-path', marker]), + ], + false, + undefined, + signal, + ), + runGit( + ['-C', path, 'worktree', 'list', '--porcelain', '-z'], + false, + undefined, + signal, + ), + runGit( + ['-C', path, 'rev-parse', '--verify', '--quiet', 'HEAD'], + false, + 1, + signal, + ), + ]); + const [commonPath, worktreePath, gitDirectory] = lines(identityOutput); + const [indexPath, ...markerPaths] = lines(gitPathsOutput); + if ( + commonPath === undefined || + worktreePath === undefined || + gitDirectory === undefined || + indexPath === undefined || + markerPaths.length !== operationMarkers.length + ) { + throw new Error('Git returned incomplete mutation inspection paths.'); + } + const [canonicalCommon, canonicalWorktree, canonicalGitDirectory] = + await Promise.all([ + realpath(commonPath), + realpath(worktreePath), + realpath(gitDirectory), + ]); + const registration = parseWorktreeListPorcelain(registrationsOutput).find( + ({ pathBytes }) => + Buffer.from(pathBytes).equals(Buffer.from(canonicalWorktree)), + ); + if (registration === undefined) { + throw new Error('The Worktree registration disappeared.'); + } + const [ + commonMetadata, + worktreeMetadata, + gitMetadata, + indexLocked, + markers, + ] = await Promise.all([ + stat(canonicalCommon), + stat(canonicalWorktree), + stat(canonicalGitDirectory), + pathExists(`${indexPath}.lock`), + Promise.all(markerPaths.map((markerPath) => pathExists(markerPath))), + ]); + const indexEvidence = await readIndexEvidence(runGit, path, paths, signal); + let evidenceIndex = 0; + const headObjectId = decodeLine(headOutput) || null; + const targetFingerprints: string[] = []; + for (const target of targets) { + const targetPathCount = target.previousPathBytes === null ? 1 : 2; + targetFingerprints.push( + await fingerprintChangedFileTarget( + canonicalWorktree, + headObjectId, + target, + indexEvidence.slice(evidenceIndex, evidenceIndex + targetPathCount), + ), + ); + evidenceIndex += targetPathCount; + } + return { + commonGitDirectory: canonicalCommon, + worktreePath: canonicalWorktree, + topologyEvidence: [ + fileIdentity(commonMetadata), + Buffer.from(canonicalWorktree).toString('base64'), + fileIdentity(worktreeMetadata), + canonicalGitDirectory, + fileIdentity(gitMetadata), + ].join('\0'), + blockedBy: registration.locked + ? 'git_lock' + : indexLocked + ? 'index_lock' + : markers.some(Boolean) + ? 'operation' + : null, + targetFingerprints, + }; + }; +} + +async function readIndexEvidence( + runGit: GitRunner, + worktreePath: string, + paths: readonly Uint8Array[], + signal: AbortSignal, +): Promise { + if (paths.length === 0) return []; + const input = Buffer.concat( + paths.flatMap((path) => [ + Buffer.from(':'), + Buffer.from(path), + Buffer.from([0]), + ]), + ); + const output = await runGit( + ['-C', worktreePath, 'cat-file', '--batch-check=%(objectname)', '-Z'], + false, + undefined, + signal, + undefined, + input, + ); + const records = splitNul(output); + if (records.length !== paths.length) { + throw new Error('Git returned incomplete Index evidence.'); + } + return records.map((record) => { + const value = Buffer.from(record).toString(); + return /^[0-9a-f]{40,64}$/u.test(value) ? `${value} 0` : 'missing'; + }); +} + +function lines(output: Uint8Array): readonly string[] { + return Buffer.from(output) + .toString() + .replace(/\r?\n$/u, '') + .split(/\r?\n/u); +} + +function decodeLine(output: Uint8Array): string { + return Buffer.from(output) + .toString() + .replace(/\r?\n$/u, ''); +} + +function splitNul(output: Uint8Array): readonly Uint8Array[] { + const records: Uint8Array[] = []; + let start = 0; + for (let index = 0; index < output.length; index += 1) { + if (output[index] !== 0) continue; + records.push(output.subarray(start, index)); + start = index + 1; + } + if (start < output.length) records.push(output.subarray(start)); + return records; +} + +function fileIdentity(metadata: Stats): string { + return `${metadata.dev}:${metadata.ino}:${metadata.birthtimeMs}`; +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} diff --git a/packages/repository-engine/src/observation-publication.ts b/packages/repository-engine/src/observation-publication.ts index 4045a14..78e145e 100644 --- a/packages/repository-engine/src/observation-publication.ts +++ b/packages/repository-engine/src/observation-publication.ts @@ -21,10 +21,15 @@ export interface PublishedRepositoryObservation { readonly worktrees: readonly PublishedObservationWorktree[]; } +export const privateWorktreeIdentityEvidence = Symbol( + 'privateWorktreeIdentityEvidence', +); + export interface PublishedObservationWorktree extends Omit< DiscoveredWorktree, - 'canonicalPathBytes' + 'canonicalPathBytes' | 'privateIdentityEvidence' > { + readonly [privateWorktreeIdentityEvidence]?: string; readonly worktreeRevision: number; readonly freshness: WorktreeFreshness; readonly index: IndexSnapshot | null; @@ -110,6 +115,7 @@ export function publishObservedFacts( > & { readonly changes: readonly ChangedFileObservation[] } = { worktreeId: worktree.worktreeId, generation: worktree.generation, + [privateWorktreeIdentityEvidence]: worktree.privateIdentityEvidence, displayPath: worktree.displayPath, canonicalPath: worktree.canonicalPath, role: worktree.role, @@ -129,11 +135,13 @@ export function publishObservedFacts( const changes = !changed && prior !== undefined ? prior.changes - : observedFacts.changes.map((change) => ({ - ...change, - fileId: requireFileIdIssuer(issueFileId)(), - nativeTargetId: requireNativeTargetIdIssuer(issueNativeTargetId)(), - })); + : observedFacts.changes.map((change) => + publishedChangedFile( + change, + requireFileIdIssuer(issueFileId)(), + requireNativeTargetIdIssuer(issueNativeTargetId)(), + ), + ); return { ...candidate, changes, @@ -277,10 +285,17 @@ function worktreeEvidence( index: worktree.index, status: worktree.status, changes: worktree.changes.map((change) => { - if ('fileId' in change) { - return toObservedChange(change); - } - return change; + const observed = 'fileId' in change ? toObservedChange(change) : change; + return { + kind: observed.kind, + baseline: observed.baseline, + displayPath: observed.displayPath, + pathBytes: observed.pathBytes, + previousDisplayPath: observed.previousDisplayPath, + previousPathBytes: observed.previousPathBytes, + workingFilePresent: observed.workingFilePresent, + baselineFingerprint: observed.baselineFingerprint, + }; }), }); } @@ -288,7 +303,7 @@ function worktreeEvidence( function toObservedChange( change: PublishedChangedFile, ): ChangedFileObservation { - return { + const observed = { kind: change.kind, baseline: change.baseline, displayPath: change.displayPath, @@ -296,7 +311,28 @@ function toObservedChange( previousDisplayPath: change.previousDisplayPath, previousPathBytes: change.previousPathBytes, workingFilePresent: change.workingFilePresent, - } as ChangedFileObservation; + } as Omit; + Object.defineProperty(observed, 'baselineFingerprint', { + enumerable: false, + value: change.baselineFingerprint, + }); + return observed as ChangedFileObservation; +} + +function publishedChangedFile( + change: ChangedFileObservation, + fileId: FileId, + nativeTargetId: NativeTargetId, +): PublishedChangedFile { + const published = { ...change, fileId, nativeTargetId } as Omit< + PublishedChangedFile, + 'baselineFingerprint' + >; + Object.defineProperty(published, 'baselineFingerprint', { + enumerable: false, + value: change.baselineFingerprint, + }); + return published as PublishedChangedFile; } function requireFileIdIssuer( diff --git a/packages/repository-engine/src/repository-engine.ts b/packages/repository-engine/src/repository-engine.ts index ff1b1a4..c32d8a3 100644 --- a/packages/repository-engine/src/repository-engine.ts +++ b/packages/repository-engine/src/repository-engine.ts @@ -19,6 +19,7 @@ import { import { createGitEnvironment } from './git-environment.js'; import { GitReadPolicy } from './git-read-policy.js'; import { readChangedFileDiff } from './change-review.js'; +import { createFileMutationInspector } from './file-mutation-inspection.js'; import { createRepositoryObserver } from './repository-observation.js'; import { createRepositoryPublicationSession, @@ -52,6 +53,7 @@ export interface RepositoryDiscovery { export interface DiscoveredWorktree { readonly worktreeId: WorktreeId; readonly generation: WorktreeGeneration; + readonly privateIdentityEvidence: string; readonly displayPath: string; readonly canonicalPath: AbsolutePath | null; readonly canonicalPathBytes: Uint8Array; @@ -234,6 +236,7 @@ export function createRepositoryEngine(): RepositoryEngine { fetchRemote(resolved.selectedWorktreePath, remoteName, signal), diff: (worktree, fileId) => readChangedFileDiff(worktree, fileId, runGit), + inspectFileMutationTargets: createFileMutationInspector(runGit), runGit, }), ); @@ -324,6 +327,7 @@ async function discoverRepository( return toDiscoveredWorktree( registration, worktreeIdentity, + `${identity.evidence}\0${worktreeIdentity.evidence}`, index === 0 ? 'main' : 'linked', ); }); @@ -610,12 +614,14 @@ function rejectDuplicateAdminIdentities( function toDiscoveredWorktree( registration: CanonicalRegistration, identity: WorktreeIdentityState, + privateIdentityEvidence: string, role: 'main' | 'linked', ): DiscoveredWorktree { const { record } = registration; return { worktreeId: identity.worktreeId, generation: identity.generation, + privateIdentityEvidence, displayPath: decodeForDisplay(record.pathBytes), canonicalPath: registration.canonicalPath, canonicalPathBytes: registration.canonicalPathBytes.slice(), @@ -718,9 +724,10 @@ function runGit( acceptedEmptyExitCode?: 1, signal?: AbortSignal, maximumOutputBytes?: number, + input?: Uint8Array, ): Promise { return new Promise((resolvePromise, reject) => { - execFile( + const child = execFile( 'git', [...args], { @@ -752,6 +759,7 @@ function runGit( resolvePromise(stdout); }, ); + if (input !== undefined) child.stdin?.end(input); }); } diff --git a/packages/repository-engine/src/repository-observation.test.ts b/packages/repository-engine/src/repository-observation.test.ts index 35d2ca0..46889a1 100644 --- a/packages/repository-engine/src/repository-observation.test.ts +++ b/packages/repository-engine/src/repository-observation.test.ts @@ -241,6 +241,7 @@ function fixtureWorktree(path: string, id: string): DiscoveredWorktree { return { worktreeId: `worktree_${id}` as DiscoveredWorktree['worktreeId'], generation: `generation_${id}` as DiscoveredWorktree['generation'], + privateIdentityEvidence: `identity-${id}`, displayPath: path, canonicalPath: path as DiscoveredWorktree['canonicalPath'], canonicalPathBytes: Buffer.from(path), diff --git a/packages/repository-engine/src/repository-observation.ts b/packages/repository-engine/src/repository-observation.ts index 8dda19a..8cc14aa 100644 --- a/packages/repository-engine/src/repository-observation.ts +++ b/packages/repository-engine/src/repository-observation.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; -import { access } from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import { access, lstat, readlink } from 'node:fs/promises'; import type { OpaqueIdAuthority, @@ -21,6 +22,8 @@ import { import { decodeForDisplay } from './worktree-porcelain.js'; const DEFAULT_GIT_READ_CONCURRENCY = 4; +const CHANGE_FINGERPRINT_CONCURRENCY = 4; +const WORKING_FILE_HASH_LIMIT_BYTES = 4 * 1_024 * 1_024; const MAX_COHERENCE_ATTEMPTS = 3; const textDecoder = new TextDecoder('utf-8', { fatal: true }); @@ -68,6 +71,7 @@ interface ChangedFileObservationBase< readonly previousDisplayPath: string | null; readonly previousPathBytes: Uint8Array | null; readonly workingFilePresent: boolean; + readonly baselineFingerprint?: string; } export type InProgressGitOperation = @@ -345,6 +349,12 @@ async function observeWorktree( const indexPath = decodeLine(indexPathOutput); const inProgressOperation = await detectInProgressOperation(operationPathsOutput); + const changes = await fingerprintChangedFiles( + worktree.canonicalPath, + observed.head, + indexOutput, + observed.changes, + ); return { kind: 'fresh', worktreeId: worktree.worktreeId, @@ -362,7 +372,7 @@ async function observeWorktree( ...observed.status, inProgressOperation, }, - changes: observed.changes, + changes, upstream: resolveUpstream(observed.upstream, shared), }; } catch (error) { @@ -668,6 +678,184 @@ function changedFile( } as Extract; } +async function fingerprintChangedFiles( + worktreePath: string, + head: DiscoveredHead, + indexOutput: Uint8Array, + changes: readonly ChangedFileObservation[], +): Promise { + return mapWithConcurrency( + changes, + CHANGE_FINGERPRINT_CONCURRENCY, + async (change) => { + const paths = [change.pathBytes, change.previousPathBytes].filter( + (path): path is Uint8Array => path !== null, + ); + return withBaselineFingerprint( + { ...change }, + await fingerprintChangedFileTarget( + worktreePath, + head.objectId, + change, + paths.map((path) => indexPathEvidence(indexOutput, path)), + ), + ); + }, + ); +} + +export async function fingerprintChangedFileTarget( + worktreePath: string, + headObjectId: string | null, + change: Pick< + ChangedFileObservation, + 'kind' | 'pathBytes' | 'previousPathBytes' + >, + indexEvidence: readonly string[], +): Promise { + const paths = [change.pathBytes, change.previousPathBytes].filter( + (path): path is Uint8Array => path !== null, + ); + const workingEvidence: string[] = []; + for (const path of paths) { + workingEvidence.push(await fingerprintWorkingPath(worktreePath, path)); + } + return createHash('sha256') + .update(change.kind) + .update('\0') + .update(headObjectId ?? 'initial') + .update('\0') + .update(fingerprintIndexEvidence(indexEvidence)) + .update('\0') + .update(workingEvidence.join('\0')) + .digest('hex'); +} + +async function fingerprintWorkingPath( + worktreePath: string, + relativePath: Uint8Array, +): Promise { + const path = Buffer.concat([ + Buffer.from(worktreePath), + Buffer.from('/'), + Buffer.from(relativePath), + ]); + let identity = 'unresolved'; + try { + const metadata = await lstat(path); + identity = `${metadata.dev}:${metadata.ino}:${metadata.birthtimeMs}:${metadata.mode}:${metadata.size}:${metadata.mtimeMs}:${metadata.ctimeMs}`; + if (metadata.isSymbolicLink()) { + try { + const target = await readlink(path, { encoding: 'buffer' }); + return createHash('sha256') + .update(`symlink\0${identity}\0`) + .update(target) + .digest('hex'); + } catch (error) { + return `symlink-unreadable:${identity}:${errorCode(error)}`; + } + } + if (metadata.isFile()) { + return fingerprintRegularFile(path, identity); + } + return `non-file:${identity}`; + } catch (error) { + if (isMissingPath(error)) return 'missing'; + return `unreadable:${identity}:${errorCode(error)}`; + } +} + +async function fingerprintRegularFile( + path: Buffer, + identity: string, +): Promise { + const hash = createHash('sha256').update(`file\0${identity}\0`); + let bytesRead = 0; + try { + for await (const value of createReadStream(path, { + highWaterMark: 64 * 1_024, + })) { + const chunk = Buffer.from(value); + const remaining = WORKING_FILE_HASH_LIMIT_BYTES - bytesRead; + if (remaining <= 0) break; + const accepted = chunk.subarray(0, remaining); + hash.update(accepted); + bytesRead += accepted.length; + if (accepted.length < chunk.length) break; + } + return hash.update(`\0sampled:${bytesRead}`).digest('hex'); + } catch (error) { + return `file-unreadable:${identity}:${errorCode(error)}`; + } +} + +function indexPathEvidence(indexOutput: Uint8Array, path: Uint8Array): string { + const entries: string[] = []; + for (const record of splitNul(indexOutput)) { + const tab = record.indexOf(0x09); + if (tab < 0) continue; + const recordPath = record.subarray(tab + 1); + if (!Buffer.from(path).equals(Buffer.from(recordPath))) continue; + const firstSpace = record.indexOf(0x20); + const secondSpace = record.indexOf(0x20, firstSpace + 1); + if (firstSpace < 0 || secondSpace < 0) continue; + entries.push(Buffer.from(record.subarray(firstSpace + 1, tab)).toString()); + } + return entries.length === 0 ? 'missing' : entries.join(','); +} + +function fingerprintIndexEvidence(evidence: readonly string[]): string { + const hash = createHash('sha256'); + for (const value of evidence) hash.update(value).update('\0'); + return hash.digest('hex'); +} + +async function mapWithConcurrency( + values: readonly Input[], + concurrency: number, + map: (value: Input) => Promise, +): Promise { + const results = new Array(values.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(concurrency, values.length) }, async () => { + while (next < values.length) { + const index = next; + next += 1; + results[index] = await map(values[index]!); + } + }), + ); + return results; +} + +function errorCode(error: unknown): string { + return error instanceof Error && 'code' in error + ? String(error.code) + : 'unknown'; +} + +function withBaselineFingerprint( + change: ChangedFileObservation, + baselineFingerprint: string, +): ChangedFileObservation { + Object.defineProperty(change, 'baselineFingerprint', { + configurable: false, + enumerable: false, + value: baselineFingerprint, + writable: false, + }); + return change; +} + +function isMissingPath(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ); +} + function pathAfterFields( record: Uint8Array, separatorCount: number, diff --git a/packages/repository-engine/src/repository-publication.test.ts b/packages/repository-engine/src/repository-publication.test.ts index 08be01b..00b4049 100644 --- a/packages/repository-engine/src/repository-publication.test.ts +++ b/packages/repository-engine/src/repository-publication.test.ts @@ -43,6 +43,7 @@ function worktree( return { worktreeId: id as DiscoveredWorktree['worktreeId'], generation: `generation_${role}` as DiscoveredWorktree['generation'], + privateIdentityEvidence: `identity-${role}`, displayPath: canonicalPath, canonicalPath: canonicalPath as DiscoveredWorktree['canonicalPath'], canonicalPathBytes: Buffer.from(canonicalPath), diff --git a/packages/repository-engine/src/repository-session.test.ts b/packages/repository-engine/src/repository-session.test.ts index 5b7863e..b405d3e 100644 --- a/packages/repository-engine/src/repository-session.test.ts +++ b/packages/repository-engine/src/repository-session.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from 'vitest'; -import type { NativeTargetId } from '@codex-git/protocol'; +import type { + ClientCommandId, + FileId, + NativeTargetId, + WorktreeGeneration, + WorktreeId, +} from '@codex-git/protocol'; import type { ScopedRepositoryPublicationSession } from './repository-publication.js'; +import type { FileMutationInspector } from './file-mutation-inspection.js'; +import { privateWorktreeIdentityEvidence } from './observation-publication.js'; import { createRepositorySession } from './repository-session.js'; describe('Repository File native targets', () => { @@ -51,3 +59,463 @@ describe('Repository File native targets', () => { await session.close(); }); }); + +describe('Repository file mutation lanes', () => { + it('executes independent Worktree mutations concurrently', async () => { + const mutated = new Set(); + const releases = new Map( + ['/worktree-one', '/worktree-two'].map((path) => [ + path, + deferred(), + ]), + ); + const bothStarted = deferred(); + const started = new Set(); + const repository = () => ({ + kind: 'repository' as const, + repository: fakeRepository(mutated), + }); + const delegate = { + snapshot: async () => repository(), + requestRefresh: async () => repository(), + requestScopedRefresh: async () => repository(), + async *subscribe() {}, + close: async () => undefined, + } as unknown as ScopedRepositoryPublicationSession; + const session = createRepositorySession(delegate, { + inspectFileMutationTargets: fakeInspection, + async runGit(args) { + const worktreePath = args[2]!; + started.add(worktreePath); + if (started.size === 2) bothStarted.resolve(); + await releases.get(worktreePath)!.promise; + mutated.add(worktreePath); + return new Uint8Array(); + }, + }); + const opened = await session.snapshot(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const [first, second] = opened.repository.worktrees; + + const firstReceipt = await session.dispatch({ + clientCommandId: commandId(1), + command: { + kind: 'stage', + worktreeId: first!.worktreeId, + expectedWorktreeRevision: first!.worktreeRevision, + fileIds: [first!.changes[0]!.fileId], + }, + }); + const secondReceipt = await session.dispatch({ + clientCommandId: commandId(2), + command: { + kind: 'stage', + worktreeId: second!.worktreeId, + expectedWorktreeRevision: second!.worktreeRevision, + fileIds: [second!.changes[0]!.fileId], + }, + }); + + await Promise.race([ + bothStarted.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('Mutations were serialized.')), 500), + ), + ]); + releases.forEach(({ resolve }) => resolve()); + + await expect( + session.recoverOperation(firstReceipt.operationId), + ).resolves.toMatchObject({ kind: 'succeeded' }); + await expect( + session.recoverOperation(secondReceipt.operationId), + ).resolves.toMatchObject({ kind: 'succeeded' }); + await session.close(); + }); + + it('reports Unknown Outcome when Git execution throws ambiguously', async () => { + const repository = () => ({ + kind: 'repository' as const, + repository: fakeRepository(new Set()), + }); + const delegate = fakeDelegate(repository); + const session = createRepositorySession(delegate, { + inspectFileMutationTargets: fakeInspection, + async runGit() { + throw new Error('Process transport disappeared.'); + }, + }); + const opened = await session.snapshot(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + + const receipt = await session.dispatch({ + clientCommandId: commandId(3), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [worktree.changes[0]!.fileId], + }, + }); + + await expect( + session.recoverOperation(receipt.operationId), + ).resolves.toMatchObject({ + kind: 'unknown_outcome', + code: 'reconciliation_incomplete', + }); + await session.close(); + }); + + it('reports Unknown Outcome when post-mutation reconciliation fails', async () => { + const mutated = new Set(); + const repository = () => ({ + kind: 'repository' as const, + repository: fakeRepository(mutated), + }); + let refreshes = 0; + const delegate = fakeDelegate(repository, async () => { + refreshes += 1; + if (refreshes > 1) throw new Error('Refresh failed.'); + return repository(); + }); + const session = createRepositorySession(delegate, { + inspectFileMutationTargets: fakeInspection, + async runGit(args) { + mutated.add(args[2]!); + return new Uint8Array(); + }, + }); + const opened = await session.snapshot(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + + const receipt = await session.dispatch({ + clientCommandId: commandId(4), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [worktree.changes[0]!.fileId], + }, + }); + + await expect( + session.recoverOperation(receipt.operationId), + ).resolves.toMatchObject({ + kind: 'unknown_outcome', + code: 'reconciliation_incomplete', + }); + await session.close(); + }); + + it('uses one bulk inspection and one bounded inspection per target', async () => { + const mutated = new Set(); + const repository = () => ({ + kind: 'repository' as const, + repository: fakeRepository(mutated, 3), + }); + let refreshes = 0; + const delegate = fakeDelegate(repository, async () => { + refreshes += 1; + return repository(); + }); + const inspectionSizes: number[] = []; + const refreshesAtMutation: number[] = []; + const session = createRepositorySession(delegate, { + async inspectFileMutationTargets(worktree, targets, signal) { + inspectionSizes.push(targets.length); + return fakeInspection(worktree, targets, signal); + }, + async runGit(args) { + refreshesAtMutation.push(refreshes); + mutated.add(args[2]!); + return new Uint8Array(); + }, + }); + const opened = await session.snapshot(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + + const receipt = await session.dispatch({ + clientCommandId: commandId(5), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: worktree.changes.map(({ fileId }) => fileId), + }, + }); + + await expect( + session.recoverOperation(receipt.operationId), + ).resolves.toMatchObject({ kind: 'succeeded' }); + expect(inspectionSizes).toEqual([3, 1, 1, 1]); + expect(refreshesAtMutation).toEqual([1, 1, 1]); + await session.close(); + }); + + it('rejects same-path Worktree replacement between refresh and baseline inspection', async () => { + const repository = () => ({ + kind: 'repository' as const, + repository: fakeRepository(new Set()), + }); + let mutations = 0; + const session = createRepositorySession(fakeDelegate(repository), { + async inspectFileMutationTargets(worktree, targets, signal) { + const inspection = await fakeInspection(worktree, targets, signal); + return { + ...inspection, + topologyEvidence: 'replacement-with-identical-visible-state', + }; + }, + async runGit() { + mutations += 1; + return new Uint8Array(); + }, + }); + const opened = await session.snapshot(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + + const receipt = await session.dispatch({ + clientCommandId: commandId(8), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [worktree.changes[0]!.fileId], + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ kind: 'rejected', code: 'stale' }); + expect(mutations).toBe(0); + await session.close(); + }); + + it('preserves a stale bulk effect when final state becomes desired externally', async () => { + const mutated = new Set(); + const repository = () => ({ + kind: 'repository' as const, + repository: fakeRepository(mutated, 2), + }); + let inspections = 0; + const session = createRepositorySession(fakeDelegate(repository), { + async inspectFileMutationTargets(worktree, targets, signal) { + inspections += 1; + const inspection = await fakeInspection(worktree, targets, signal); + return inspections === 3 + ? { ...inspection, targetFingerprints: ['externally-changed'] } + : inspection; + }, + async runGit(args) { + mutated.add(args[2]!); + return new Uint8Array(); + }, + }); + const opened = await session.snapshot(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + + const receipt = await session.dispatch({ + clientCommandId: commandId(6), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: worktree.changes.map(({ fileId }) => fileId), + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'partial_success', + effects: [ + { kind: 'succeeded', label: 'file-1-0.txt' }, + { kind: 'failed_known', label: 'file-1-1.txt', code: 'stale' }, + ], + }); + await session.close(); + }); + + it('blocks a later bulk target when a Git operation starts mid-bulk', async () => { + const mutated = new Set(); + const repository = () => ({ + kind: 'repository' as const, + repository: fakeRepository(mutated, 2), + }); + let inspections = 0; + let mutations = 0; + const session = createRepositorySession(fakeDelegate(repository), { + async inspectFileMutationTargets(worktree, targets, signal) { + inspections += 1; + const inspection = await fakeInspection(worktree, targets, signal); + return inspections === 3 + ? { ...inspection, blockedBy: 'operation' } + : inspection; + }, + async runGit(args) { + mutations += 1; + mutated.add(args[2]!); + return new Uint8Array(); + }, + }); + const opened = await session.snapshot(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = opened.repository.worktrees[0]!; + + const receipt = await session.dispatch({ + clientCommandId: commandId(7), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: worktree.changes.map(({ fileId }) => fileId), + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(mutations).toBe(1); + expect(result).toMatchObject({ + kind: 'partial_success', + effects: [ + { kind: 'succeeded', label: 'file-1-0.txt' }, + { + kind: 'failed_known', + label: 'file-1-1.txt', + code: 'precondition_failed', + }, + ], + }); + await session.close(); + }); +}); + +type FakeRepositoryResult = { + kind: 'repository'; + repository: ReturnType; +}; + +const fakeInspection: FileMutationInspector = async (worktree, targets) => { + if (worktree.canonicalPath === null) + throw new Error('Expected Worktree path'); + return { + commonGitDirectory: '/common.git', + worktreePath: worktree.canonicalPath, + topologyEvidence: `topology:${worktree.canonicalPath}`, + blockedBy: null, + targetFingerprints: targets.map(({ baselineFingerprint }) => + String(baselineFingerprint), + ), + } as const; +}; + +function fakeDelegate( + repository: () => FakeRepositoryResult, + requestRefresh: () => Promise = async () => + repository(), +) { + return { + snapshot: async () => repository(), + requestRefresh, + requestScopedRefresh: requestRefresh, + async *subscribe() {}, + close: async () => undefined, + } as unknown as ScopedRepositoryPublicationSession; +} + +function fakeRepository(mutated: ReadonlySet, fileCount = 1) { + return { + repositoryId: 'repository_00000000000000000000000000000001', + commonGitDirectory: '/common.git', + selectedWorktreeId: + 'worktree_00000000000000000000000000000001' as WorktreeId, + repositoryRevision: mutated.size + 1, + topologyRevision: 1, + refsRevision: 1, + refresh: { kind: 'fresh' as const }, + fetch: { kind: 'never' as const }, + remotes: [], + refs: [], + operations: [], + worktrees: [ + fakeWorktree(1, '/worktree-one', mutated.has('/worktree-one'), fileCount), + fakeWorktree(2, '/worktree-two', mutated.has('/worktree-two')), + ], + }; +} + +function fakeWorktree( + index: number, + path: string, + staged: boolean, + fileCount = 1, +) { + const suffix = index.toString(16).padStart(32, '0'); + return { + worktreeId: `worktree_${suffix}` as WorktreeId, + worktreeRevision: staged ? 2 : 1, + generation: `generation_${suffix}` as WorktreeGeneration, + [privateWorktreeIdentityEvidence]: `topology:${path}`, + displayPath: path, + canonicalPath: path, + role: index === 1 ? ('main' as const) : ('linked' as const), + head: { + kind: 'local_branch' as const, + fullName: `refs/heads/worktree-${index}`, + displayName: `worktree-${index}`, + objectId: '0123456789abcdef0123456789abcdef01234567', + }, + gitLock: { kind: 'unlocked' as const }, + availability: { kind: 'available' as const }, + freshness: { kind: 'fresh' as const }, + index: { + entryCount: staged ? 1 : 0, + fingerprint: String(index), + locked: false, + }, + status: { + clean: false, + conflicted: 0, + staged: staged ? 1 : 0, + unstaged: staged ? 0 : 1, + untracked: 0, + }, + changes: Array.from({ length: fileCount }, (_, fileIndex) => { + const fileSuffix = (index * 1_000 + fileIndex) + .toString(16) + .padStart(32, '0'); + return { + fileId: `file_${fileSuffix}` as FileId, + nativeTargetId: `native_${fileSuffix}` as NativeTargetId, + kind: staged ? ('staged_change' as const) : ('change' as const), + baseline: staged + ? ('head_to_index' as const) + : ('index_to_working_tree' as const), + baselineFingerprint: staged + ? `staged-${index}-${fileIndex}` + : `changed-${index}-${fileIndex}`, + displayPath: `file-${index}-${fileIndex}.txt`, + pathBytes: new TextEncoder().encode(`file-${index}-${fileIndex}.txt`), + previousDisplayPath: null, + previousPathBytes: null, + workingFilePresent: true, + }; + }), + upstream: { kind: 'unpublished' as const }, + }; +} + +function commandId(index: number) { + return `command_${index.toString(16).padStart(32, '0')}` as ClientCommandId; +} + +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 1b9f19b..4ddc39d 100644 --- a/packages/repository-engine/src/repository-session.ts +++ b/packages/repository-engine/src/repository-session.ts @@ -23,6 +23,7 @@ import { type OperationSessionAdmission, type OperationSessionSummary, } from './operation-session.js'; +import type { FileMutationInspector } from './file-mutation-inspection.js'; import type { RepositoryInvalidation, RepositoryOpenResult, @@ -31,6 +32,7 @@ import type { RepositorySnapshot, ScopedRepositoryPublicationSession, } from './repository-publication.js'; +import { privateWorktreeIdentityEvidence } from './observation-publication.js'; const OPERATION_TIMEOUT_MILLISECONDS = 30_000; @@ -65,6 +67,7 @@ export interface RepositorySessionOptions { ) => Promise; readonly now?: () => Date; readonly runGit?: GitProcessRunner; + readonly inspectFileMutationTargets?: FileMutationInspector; readonly diff?: ( worktree: RepositorySnapshot['worktrees'][number], fileId: FileId, @@ -119,6 +122,8 @@ type GitProcessRunner = ( allowLargeOutput: boolean, acceptedEmptyExitCode?: 1, signal?: AbortSignal, + maximumOutputBytes?: number, + input?: Uint8Array, ) => Promise; interface BranchBinding { @@ -618,6 +623,376 @@ export function createRepositorySession( }; }, async dispatch(request) { + if ( + request.command.kind === 'stage' || + request.command.kind === 'unstage' + ) { + const command = request.command; + const initial = latestBase?.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ); + if (initial === undefined) { + throw new Error( + 'Stage and Unstage require a current Worktree snapshot.', + ); + } + const initialRepositoryId = latestBase?.repositoryId; + const initialCommonGitDirectory = latestBase?.commonGitDirectory; + const initialGeneration = initial.generation; + const initialCanonicalPath = initial.canonicalPath; + const admission = await operations.dispatch({ + kind: command.kind, + worktreeGeneration: initial.generation, + 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; + if ( + current?.kind !== 'repository' || + current.repository.repositoryId !== initialRepositoryId || + current.repository.commonGitDirectory !== + initialCommonGitDirectory || + worktree === undefined || + worktree.generation !== initialGeneration || + worktree.canonicalPath !== initialCanonicalPath || + worktree.worktreeRevision !== command.expectedWorktreeRevision + ) { + return reject( + 'stale', + 'Worktree or Changed File state changed; refresh and choose again.', + ); + } + const changes = command.fileIds.map((fileId) => + worktree.changes.find((change) => change.fileId === fileId), + ); + if (changes.some((change) => change === undefined)) { + return reject( + 'stale', + 'A Changed File target changed; refresh and choose again.', + ); + } + const resolvedChanges = changes.filter( + (change): change is NonNullable => + change !== undefined, + ); + if ( + command.kind === 'stage' && + resolvedChanges.some((change) => change.kind === 'conflict') + ) { + return reject( + 'unsupported_state', + 'Conflict entries cannot be staged.', + ); + } + const targetKindsMatch = resolvedChanges.every((change) => + command.kind === 'stage' + ? change.kind === 'change' || change.kind === 'untracked' + : change.kind === 'staged_change', + ); + if ( + !targetKindsMatch || + worktree.availability.kind !== 'available' || + worktree.canonicalPath === null || + worktree.freshness.kind !== 'fresh' || + worktree.status?.inProgressOperation !== undefined || + worktree.index?.locked !== false || + worktree.gitLock.kind !== 'unlocked' + ) { + return reject( + 'precondition_failed', + `${command.kind === 'stage' ? 'Stage' : 'Unstage'} requires current Changed Files in an available Worktree with no Git lock.`, + ); + } + if (options.runGit === undefined) { + return reject( + 'unsupported_state', + `${command.kind === 'stage' ? 'Stage' : 'Unstage'} is unavailable in this Repository Session.`, + ); + } + if (options.inspectFileMutationTargets === undefined) { + return reject( + 'unsupported_state', + `${command.kind === 'stage' ? 'Stage' : 'Unstage'} target inspection is unavailable in this Repository Session.`, + ); + } + const baselineInspection = await options.inspectFileMutationTargets( + worktree, + resolvedChanges, + signal, + ); + if ( + baselineInspection.topologyEvidence !== + worktree[privateWorktreeIdentityEvidence] || + baselineInspection.commonGitDirectory !== + initialCommonGitDirectory || + baselineInspection.worktreePath !== initialCanonicalPath || + baselineInspection.targetFingerprints.length !== + resolvedChanges.length || + baselineInspection.targetFingerprints.some( + (fingerprint, index) => + fingerprint !== resolvedChanges[index]?.baselineFingerprint, + ) + ) { + return reject( + 'stale', + 'Repository, Worktree, or Changed File state changed before inspection.', + ); + } + if (baselineInspection.blockedBy !== null) { + return reject( + baselineInspection.blockedBy === 'index_lock' + ? 'index_locked' + : 'precondition_failed', + 'A Git operation or lock blocks this file mutation.', + ); + } + const effects = []; + for (let index = 0; index < resolvedChanges.length; index += 1) { + const target = resolvedChanges[index]!; + const inspection = await options.inspectFileMutationTargets( + worktree, + [target], + signal, + ); + if ( + inspection.commonGitDirectory !== initialCommonGitDirectory || + inspection.worktreePath !== initialCanonicalPath || + inspection.topologyEvidence !== + baselineInspection.topologyEvidence || + inspection.targetFingerprints[0] !== target.baselineFingerprint + ) { + if (effects.length === 0) { + return reject( + 'stale', + 'Repository or Worktree identity changed; refresh and choose again.', + ); + } + effects.push(staleFileEffect(target)); + continue; + } + if (inspection.blockedBy !== null) { + const code = + inspection.blockedBy === 'index_lock' + ? ('index_locked' as const) + : ('precondition_failed' as const); + if (effects.length === 0) { + return reject( + code, + 'A Git operation or lock blocks this file mutation.', + ); + } + effects.push(blockedFileEffect(target, code)); + continue; + } + const change = target; + const paths = [change.pathBytes]; + if (change.previousPathBytes !== null) { + paths.push(change.previousPathBytes); + } + try { + await options.runGit( + fileMutationArguments( + command.kind, + worktree.canonicalPath, + worktree.head.objectId === null, + ), + false, + undefined, + signal, + undefined, + nulDelimitedPaths(paths), + ); + effects.push({ + kind: 'completed' as const, + label: effectLabel(change.displayPath), + pathBytes: change.pathBytes, + sourceKind: change.kind, + }); + } catch (error) { + if (signal.aborted) throw error; + if (!isKnownGitFailure(error)) throw error; + effects.push({ + kind: 'failed_known' as const, + label: effectLabel(change.displayPath), + pathBytes: change.pathBytes, + sourceKind: change.kind, + code: 'process_failed' as const, + message: `Git could not ${command.kind} ${effectLabel(change.displayPath)}.`, + }); + } + } + return { + kind: 'attempted' as const, + effects, + }; + }, + 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 unknownFileMutation(); + } + if (evidence?.kind === 'rejected') return evidence.result; + if (context.execution.kind !== 'returned') { + return unknownFileMutation(); + } + if ( + evidence?.kind === 'attempted' && + reconciled.kind === 'repository' + ) { + 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 { + kind: 'unknown_outcome', + code: 'reconciliation_incomplete', + message: 'Fresh Worktree state could not be established.', + recoveryAvailable: true, + }; + } + const effects = evidence.effects.map((effect) => { + if ( + effect.kind === 'failed_known' && + (effect.code === 'stale' || + effect.code === 'index_locked' || + effect.code === 'precondition_failed') + ) { + return { + kind: effect.kind, + label: effect.label, + code: effect.code, + message: effect.message, + }; + } + const currentChanges = worktree.changes.filter((change) => + Buffer.from(change.pathBytes).equals( + Buffer.from(effect.pathBytes), + ), + ); + const desiredState = + command.kind === 'stage' && effect.sourceKind === 'untracked' + ? currentChanges.some( + ({ kind }) => kind === 'staged_change', + ) + : !currentChanges.some(({ kind }) => + command.kind === 'stage' + ? kind === 'change' || kind === 'untracked' + : kind === 'staged_change', + ); + return desiredState + ? { kind: 'succeeded' as const, label: effect.label } + : effect.kind === 'failed_known' + ? { + kind: effect.kind, + label: effect.label, + code: effect.code, + message: effect.message, + } + : { + kind: 'failed_known' as const, + label: effect.label, + code: 'process_failed' as const, + message: `Git did not ${command.kind} ${effect.label}.`, + }; + }); + const succeeded = effects.filter( + ( + effect, + ): effect is Extract< + (typeof effects)[number], + { kind: 'succeeded' } + > => effect.kind === 'succeeded', + ); + const failed = effects.filter( + ( + effect, + ): effect is Extract< + (typeof effects)[number], + { kind: 'failed_known' } + > => effect.kind === 'failed_known', + ); + if (failed.length === 0) { + return { + kind: 'succeeded', + result: { + kind: 'files', + affectedCount: succeeded.length, + }, + }; + } + if (succeeded.length > 0) { + return { + kind: 'partial_success', + message: `Some Changed Files could not be ${command.kind === 'stage' ? 'staged' : 'unstaged'}.`, + effects, + }; + } + const firstFailure = failed[0]!; + if (failed.every(({ code }) => code === 'stale')) { + return { + kind: 'rejected', + code: 'stale', + message: 'Changed File targets changed before execution.', + }; + } + return { + kind: 'failed_known', + code: + firstFailure.code === 'stale' || + firstFailure.code === 'index_locked' || + firstFailure.code === 'precondition_failed' + ? 'process_failed' + : firstFailure.code, + message: + failed.length === 1 + ? firstFailure.message + : `No Changed Files could be ${command.kind === 'stage' ? 'staged' : 'unstaged'}.`, + effects: failed.length > 1 ? failed : undefined, + }; + } + return unknownFileMutation(); + }, + }); + 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.', @@ -851,12 +1226,12 @@ export function createRepositorySession( }, async cancelOperation(operationId) { const result = await operations.cancel(operationId); - await observe(() => delegate.requestRefresh()); + await observe(() => delegate.requestRefresh()).catch(() => undefined); return result; }, async recoverOperation(operationId) { const result = await operations.recover(operationId); - await observe(() => delegate.requestRefresh()); + await observe(() => delegate.requestRefresh()).catch(() => undefined); return result; }, async close() { @@ -870,6 +1245,112 @@ export function createRepositorySession( }; } +function nulDelimitedPaths(paths: readonly Uint8Array[]): Uint8Array { + const length = paths.reduce((total, path) => total + path.length + 1, 0); + const input = new Uint8Array(length); + let offset = 0; + for (const path of paths) { + input.set(path, offset); + offset += path.length + 1; + } + return input; +} + +type ChangedFileTarget = + RepositorySnapshot['worktrees'][number]['changes'][number]; + +function staleFileEffect(change: ChangedFileTarget) { + return { + kind: 'failed_known' as const, + label: effectLabel(change.displayPath), + pathBytes: change.pathBytes, + sourceKind: change.kind, + code: 'stale' as const, + message: `${effectLabel(change.displayPath)} changed before execution.`, + }; +} + +function blockedFileEffect( + change: ChangedFileTarget, + code: 'index_locked' | 'precondition_failed', +) { + return { + kind: 'failed_known' as const, + label: effectLabel(change.displayPath), + pathBytes: change.pathBytes, + sourceKind: change.kind, + code, + message: `${effectLabel(change.displayPath)} is blocked by a Git operation or lock.`, + }; +} + +function isKnownGitFailure(error: unknown): boolean { + return ( + error instanceof Error && + 'failure' in error && + error.failure === 'command_failed' && + 'exitCode' in error && + typeof error.exitCode === 'number' + ); +} + +function unknownFileMutation() { + return { + kind: 'unknown_outcome' as const, + code: 'reconciliation_incomplete' as const, + message: 'The file mutation could not be reconciled to fresh state.', + recoveryAvailable: true as const, + }; +} + +function fileMutationArguments( + kind: 'stage' | 'unstage', + worktreePath: string, + initialState: boolean, +): readonly string[] { + if (kind === 'stage') { + return [ + '--literal-pathspecs', + '-C', + worktreePath, + 'add', + '-A', + '--pathspec-from-file=-', + '--pathspec-file-nul', + ]; + } + if (initialState) { + return [ + '--literal-pathspecs', + '-C', + worktreePath, + 'rm', + '--cached', + '--force', + '--quiet', + '--ignore-unmatch', + '--pathspec-from-file=-', + '--pathspec-file-nul', + ]; + } + return [ + '--literal-pathspecs', + '-C', + worktreePath, + 'reset', + '--quiet', + 'HEAD', + '--pathspec-from-file=-', + '--pathspec-file-nul', + ]; +} + +function effectLabel(displayPath: string): string { + return displayPath.length <= 256 + ? displayPath + : `${displayPath.slice(0, 253)}...`; +} + function escapedBytePath(path: Uint8Array): string { return [...path] .map((byte) => diff --git a/tests/integration/repository-stage-unstage.integration.test.ts b/tests/integration/repository-stage-unstage.integration.test.ts new file mode 100644 index 0000000..1090a20 --- /dev/null +++ b/tests/integration/repository-stage-unstage.integration.test.ts @@ -0,0 +1,735 @@ +import { + chmod, + readFile, + rename, + rm, + unlink, + 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 Stage and Unstage', () => { + it('stages a Changed File in only the selected Worktree Index', async () => { + const repository = await createRepositoryWithCommit(); + await repository.git('branch', 'linked'); + const linkedPath = `${repository.path}-linked`; + externalPaths.push(linkedPath); + await repository.git('worktree', 'add', '--quiet', linkedPath, 'linked'); + await writeFile(join(repository.path, 'README.md'), 'main change\n'); + await writeFile(join(linkedPath, 'README.md'), 'linked change\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 main = opened.repository.worktrees.find( + ({ role }) => role === 'main', + ); + if (main === undefined) throw new Error('Expected Main Worktree'); + const changed = main.changes.find(({ kind }) => kind === 'change'); + if (changed === undefined) throw new Error('Expected Change'); + + const receipt = await session.dispatch({ + clientCommandId: commandId(1), + command: { + kind: 'stage', + worktreeId: main.worktreeId, + expectedWorktreeRevision: main.worktreeRevision, + fileIds: [changed.fileId], + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'succeeded', + result: { kind: 'files', affectedCount: 1 }, + }); + expect( + (await repository.git('diff', '--cached', '--', 'README.md')).stdout, + ).toContain('+main change'); + expect( + ( + await repository.git( + '-C', + linkedPath, + 'diff', + '--cached', + '--', + 'README.md', + ) + ).stdout, + ).toBe(''); + await session.close(); + }); + + it('unstages a Staged Change without modifying Working Tree bytes', async () => { + const repository = await createRepositoryWithCommit(); + const workingBytes = Buffer.from('staged content\nworking content\n'); + await writeFile(join(repository.path, 'README.md'), 'staged content\n'); + await repository.git('add', 'README.md'); + await writeFile(join(repository.path, 'README.md'), workingBytes); + 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]; + if (worktree === undefined) throw new Error('Expected Worktree'); + const staged = worktree.changes.find( + ({ kind }) => kind === 'staged_change', + ); + if (staged === undefined) throw new Error('Expected Staged Change'); + + const receipt = await session.dispatch({ + clientCommandId: commandId(2), + command: { + kind: 'unstage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [staged.fileId], + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'succeeded', + result: { kind: 'files', affectedCount: 1 }, + }); + expect( + (await repository.git('diff', '--cached', '--', 'README.md')).stdout, + ).toBe(''); + expect(await readFile(join(repository.path, 'README.md'))).toEqual( + workingBytes, + ); + await session.close(); + }); + + it('unstages safely before the Initial Commit', async () => { + const repository = await createTemporaryGitRepository(); + repositories.push(repository); + const workingBytes = Buffer.from('initial content\n'); + await writeFile(join(repository.path, 'new file.txt'), workingBytes); + await repository.git('add', 'new file.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]; + if (worktree === undefined) throw new Error('Expected Worktree'); + const staged = worktree.changes.find( + ({ kind }) => kind === 'staged_change', + ); + if (staged === undefined) throw new Error('Expected Staged Change'); + + const receipt = await session.dispatch({ + clientCommandId: commandId(3), + command: { + kind: 'unstage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [staged.fileId], + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'succeeded', + result: { kind: 'files', affectedCount: 1 }, + }); + expect( + (await repository.git('diff', '--cached', '--', 'new file.txt')).stdout, + ).toBe(''); + expect((await repository.git('status', '--short', '-z')).stdout).toContain( + '?? new file.txt', + ); + expect(await readFile(join(repository.path, 'new file.txt'))).toEqual( + workingBytes, + ); + await session.close(); + }); + + it('rejects stale file evidence and returns current Worktree state', async () => { + const repository = await createRepositoryWithCommit(); + await writeFile(join(repository.path, 'README.md'), 'reviewed change\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 reviewed = worktree?.changes.find(({ kind }) => kind === 'change'); + if (worktree === undefined || reviewed === undefined) { + throw new Error('Expected reviewed Change'); + } + await writeFile(join(repository.path, 'README.md'), 'external change\n'); + + const receipt = await session.dispatch({ + clientCommandId: commandId(4), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [reviewed.fileId], + }, + }); + const result = await session.recoverOperation(receipt.operationId); + const current = await session.requestRefresh(); + + expect(result).toMatchObject({ kind: 'rejected', code: 'stale' }); + expect( + (await repository.git('diff', '--cached', '--', 'README.md')).stdout, + ).toBe(''); + expect(current).toMatchObject({ + kind: 'repository', + repository: { + worktrees: [ + expect.objectContaining({ + changes: [ + expect.objectContaining({ + kind: 'change', + displayPath: 'README.md', + }), + ], + }), + ], + }, + }); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + expect(current.repository.worktrees[0]?.changes[0]?.fileId).not.toBe( + reviewed.fileId, + ); + await session.close(); + }); + + it('reports per-path Partial Success without rollback claims', async () => { + 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, '.gitattributes'), + 'gate.txt filter=gate\nbad.txt filter=reject\n', + ); + await writeFile(join(repository.path, 'good.txt'), 'initial good\n'); + await writeFile(join(repository.path, 'bad.txt'), 'initial bad\n'); + await writeFile(join(repository.path, 'gate.txt'), 'initial gate\n'); + await repository.git( + 'add', + '.gitattributes', + 'good.txt', + 'bad.txt', + 'gate.txt', + ); + await repository.git('commit', '--quiet', '-m', 'Initial fixture'); + const gatePath = join(repository.path, '..', 'codex-git-gate-filter'); + const signalPath = join(repository.path, '..', 'codex-git-gate-signal'); + const releasePath = join(repository.path, '..', 'codex-git-gate-release'); + const rejectPath = join(repository.path, '..', 'codex-git-reject-filter'); + const rejectCountPath = join( + repository.path, + '..', + 'codex-git-reject-count', + ); + externalPaths.push( + gatePath, + signalPath, + releasePath, + rejectPath, + rejectCountPath, + ); + await writeFile( + gatePath, + '#!/bin/sh\ntouch "$1"\nwhile [ ! -f "$2" ]; do sleep 0.01; done\ncat\n', + ); + await chmod(gatePath, 0o700); + await writeFile( + rejectPath, + '#!/bin/sh\ncount=$(cat "$1")\ncount=$((count + 1))\nprintf "%s" "$count" > "$1"\nif [ "$count" -eq 1 ]; then exit 1; fi\ncat\n', + ); + await chmod(rejectPath, 0o700); + await writeFile(rejectCountPath, '0'); + await writeFile(join(repository.path, 'good.txt'), 'changed good\n'); + await writeFile(join(repository.path, 'bad.txt'), 'changed bad\n'); + await writeFile(join(repository.path, 'gate.txt'), 'changed gate\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]; + if (worktree === undefined) throw new Error('Expected Worktree'); + const good = worktree.changes.find( + ({ displayPath }) => displayPath === 'good.txt', + ); + const bad = worktree.changes.find( + ({ displayPath }) => displayPath === 'bad.txt', + ); + if (good === undefined || bad === undefined) { + throw new Error('Expected two Changes'); + } + await repository.git( + 'config', + 'filter.gate.clean', + `${gatePath} ${signalPath} ${releasePath}`, + ); + + const receipt = await session.dispatch({ + clientCommandId: commandId(5), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [good.fileId, bad.fileId], + }, + }); + await waitForPath(signalPath); + await repository.git( + 'config', + 'filter.reject.clean', + `${rejectPath} ${rejectCountPath}`, + ); + await repository.git('config', 'filter.reject.required', 'true'); + await writeFile(releasePath, 'continue'); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'partial_success', + effects: [ + { + label: 'good.txt', + kind: 'failed_known', + code: 'process_failed', + }, + { label: 'bad.txt', kind: 'succeeded' }, + ], + }); + expect( + (await repository.git('diff', '--cached', '--', 'good.txt')).stdout, + ).toBe(''); + expect( + (await repository.git('diff', '--cached', '--', 'bad.txt')).stdout, + ).toContain('+changed bad'); + await session.close(); + }); + + it('passes unusual paths literally through group Stage', async () => { + const repository = await createRepositoryWithCommit(); + const paths = [ + '-leading.txt', + 'space name.txt', + 'unicodé.txt', + 'line\nbreak.txt', + ]; + await Promise.all( + paths.map((path) => + writeFile(join(repository.path, path), `content for ${path}\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]; + if (worktree === undefined) throw new Error('Expected Worktree'); + const untracked = worktree.changes.filter( + ({ kind }) => kind === 'untracked', + ); + + const receipt = await session.dispatch({ + clientCommandId: commandId(6), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: untracked.map(({ fileId }) => fileId), + }, + }); + const result = await session.recoverOperation(receipt.operationId); + const stagedPaths = (await repository.git('ls-files', '-z')).stdout + .split('\0') + .filter(Boolean); + + expect(result).toMatchObject({ + kind: 'succeeded', + result: { kind: 'files', affectedCount: 4 }, + }); + expect(stagedPaths).toEqual(expect.arrayContaining(paths)); + await session.close(); + }); + + it('rejects Conflict entries without mutating the Index', async () => { + const repository = await createRepositoryWithCommit(); + await repository.git('switch', '--quiet', '-c', 'other'); + await writeFile(join(repository.path, 'README.md'), 'other\n'); + await repository.git('commit', '--quiet', '-am', 'Other change'); + await repository.git('switch', '--quiet', '-'); + await writeFile(join(repository.path, 'README.md'), 'current\n'); + await repository.git('commit', '--quiet', '-am', 'Current change'); + await expect(repository.git('merge', 'other')).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 conflict = worktree?.changes.find(({ kind }) => kind === 'conflict'); + if (worktree === undefined || conflict === undefined) { + throw new Error('Expected Conflict'); + } + const indexBefore = (await repository.git('ls-files', '--stage', '-z')) + .stdout; + + const receipt = await session.dispatch({ + clientCommandId: commandId(7), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [conflict.fileId], + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'rejected', + code: 'unsupported_state', + }); + expect((await repository.git('ls-files', '--stage', '-z')).stdout).toBe( + indexBefore, + ); + await session.close(); + }); + + it('stages renames and deletions from their opaque Changed File targets', async () => { + const repository = await createRepositoryWithCommit(); + await writeFile(join(repository.path, 'old.txt'), 'rename me\n'); + await writeFile(join(repository.path, 'delete.txt'), 'delete me\n'); + await repository.git('add', 'old.txt', 'delete.txt'); + await repository.git('commit', '--quiet', '-m', 'Path fixtures'); + await rename( + join(repository.path, 'old.txt'), + join(repository.path, 'new.txt'), + ); + await unlink(join(repository.path, 'delete.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]; + if (worktree === undefined) throw new Error('Expected Worktree'); + + const receipt = await session.dispatch({ + clientCommandId: commandId(8), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: worktree.changes + .filter(({ kind }) => kind === 'change' || kind === 'untracked') + .map(({ fileId }) => fileId), + }, + }); + const result = await session.recoverOperation(receipt.operationId); + const staged = ( + await repository.git('diff', '--cached', '--name-status', '-M') + ).stdout; + + expect(result).toMatchObject({ kind: 'succeeded' }); + expect(staged).toContain('delete.txt'); + expect(staged).toContain('old.txt'); + expect(staged).toContain('new.txt'); + await session.close(); + }); + + it('rejects a removed and recreated Worktree generation before mutation', async () => { + const repository = await createRepositoryWithCommit(); + await repository.git('branch', 'linked'); + const linkedPath = `${repository.path}-recreated`; + externalPaths.push(linkedPath); + await repository.git('worktree', 'add', '--quiet', linkedPath, 'linked'); + await writeFile(join(linkedPath, 'README.md'), 'reviewed linked change\n'); + const session = await createRepositoryEngine().open( + linkedPath as AbsolutePath, + ); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const linked = opened.repository.worktrees.find( + ({ role }) => role === 'linked', + ); + const change = linked?.changes.find(({ kind }) => kind === 'change'); + if (linked === undefined || change === undefined) { + throw new Error('Expected linked Change'); + } + await repository.git('worktree', 'remove', '--force', linkedPath); + await repository.git('worktree', 'add', '--quiet', linkedPath, 'linked'); + await writeFile(join(linkedPath, 'README.md'), 'replacement change\n'); + + const receipt = await session.dispatch({ + clientCommandId: commandId(9), + command: { + kind: 'stage', + worktreeId: linked.worktreeId, + expectedWorktreeRevision: linked.worktreeRevision, + fileIds: [change.fileId], + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ kind: 'rejected', code: 'stale' }); + expect( + (await repository.git('-C', linkedPath, 'diff', '--cached')).stdout, + ).toBe(''); + await session.close(); + }); + + it('treats pathspec-magic filenames as literal paths', async () => { + const repository = await createRepositoryWithCommit(); + const magicPath = ':(glob)*.txt'; + await writeFile(join(repository.path, magicPath), 'literal magic path\n'); + await writeFile( + join(repository.path, 'other.txt'), + 'must stay untracked\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 magic = worktree.changes.find( + ({ displayPath }) => displayPath === magicPath, + ); + if (magic === undefined) throw new Error('Expected magic-path file'); + + const receipt = await session.dispatch({ + clientCommandId: commandId(10), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [magic.fileId], + }, + }); + const result = await session.recoverOperation(receipt.operationId); + const staged = (await repository.git('ls-files', '-z')).stdout.split('\0'); + + expect(result).toMatchObject({ kind: 'succeeded' }); + expect(staged).toContain(magicPath); + expect(staged).not.toContain('other.txt'); + await session.close(); + }); + + it('force-removes an Initial Commit Index entry after another Working Tree edit', async () => { + const repository = await createTemporaryGitRepository(); + repositories.push(repository); + const path = join(repository.path, 'new.txt'); + await writeFile(path, 'staged version\n'); + await repository.git('add', 'new.txt'); + const workingBytes = Buffer.from('edited after Stage\n'); + await writeFile(path, workingBytes); + 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 staged = worktree.changes.find( + ({ kind }) => kind === 'staged_change', + ); + if (staged === undefined) throw new Error('Expected Staged Change'); + + const receipt = await session.dispatch({ + clientCommandId: commandId(11), + command: { + kind: 'unstage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [staged.fileId], + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ kind: 'succeeded' }); + expect((await repository.git('ls-files', '-z')).stdout).toBe(''); + expect(await readFile(path)).toEqual(workingBytes); + await session.close(); + }); + + it('revalidates every bulk target immediately before its Git command', async () => { + 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, '.gitattributes'), + 'gate.txt filter=gate\nfirst.txt filter=mutate-later\n', + ); + await writeFile(join(repository.path, 'gate.txt'), 'initial gate\n'); + await writeFile(join(repository.path, 'first.txt'), 'initial first\n'); + await writeFile(join(repository.path, 'later.txt'), 'initial later\n'); + await repository.git( + 'add', + '.gitattributes', + 'gate.txt', + 'first.txt', + 'later.txt', + ); + await repository.git('commit', '--quiet', '-m', 'Bulk fixture'); + await writeFile(join(repository.path, 'gate.txt'), 'changed gate\n'); + await writeFile(join(repository.path, 'first.txt'), 'changed first\n'); + await writeFile(join(repository.path, 'later.txt'), 'reviewed later\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 first = worktree.changes.find( + ({ displayPath }) => displayPath === 'first.txt', + ); + const later = worktree.changes.find( + ({ displayPath }) => displayPath === 'later.txt', + ); + if (first === undefined || later === undefined) { + throw new Error('Expected bulk targets'); + } + const gateScript = `${repository.path}-bulk-gate`; + const gateSignal = `${repository.path}-bulk-signal`; + const gateRelease = `${repository.path}-bulk-release`; + const mutateScript = `${repository.path}-mutate-later`; + externalPaths.push(gateScript, gateSignal, gateRelease, mutateScript); + await writeFile( + gateScript, + '#!/bin/sh\ntouch "$1"\nwhile [ ! -f "$2" ]; do sleep 0.01; done\ncat\n', + ); + await chmod(gateScript, 0o700); + await writeFile( + mutateScript, + '#!/bin/sh\nprintf "external later\\n" > "$1"\ncat\n', + ); + await chmod(mutateScript, 0o700); + await repository.git( + 'config', + 'filter.gate.clean', + `${gateScript} ${gateSignal} ${gateRelease}`, + ); + + const receipt = await session.dispatch({ + clientCommandId: commandId(12), + command: { + kind: 'stage', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + fileIds: [first.fileId, later.fileId], + }, + }); + await waitForPath(gateSignal); + await repository.git( + 'config', + 'filter.mutate-later.clean', + `${mutateScript} ${join(repository.path, 'later.txt')}`, + ); + await writeFile(gateRelease, 'continue'); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'partial_success', + effects: [ + { kind: 'succeeded', label: 'first.txt' }, + { kind: 'failed_known', label: 'later.txt', code: 'stale' }, + ], + }); + expect( + (await repository.git('diff', '--cached', '--', 'first.txt')).stdout, + ).toContain('+changed first'); + expect( + (await repository.git('diff', '--cached', '--', 'later.txt')).stdout, + ).toBe(''); + await session.close(); + }); + + it('keeps large and unreadable Changed Files observable with bounded evidence', async () => { + const repository = await createRepositoryWithCommit(); + const largePath = join(repository.path, 'large.bin'); + const unreadablePath = join(repository.path, 'unreadable.txt'); + await writeFile(largePath, Buffer.alloc(5 * 1_024 * 1_024, 0x61)); + await writeFile(unreadablePath, 'unreadable evidence\n'); + await chmod(unreadablePath, 0o000); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + + const opened = await session.requestRefresh(); + + expect(opened).toMatchObject({ + kind: 'repository', + repository: { + refresh: { kind: 'fresh' }, + worktrees: [ + expect.objectContaining({ + freshness: { kind: 'fresh' }, + changes: expect.arrayContaining([ + expect.objectContaining({ displayPath: 'large.bin' }), + expect.objectContaining({ displayPath: 'unreadable.txt' }), + ]), + }), + ], + }, + }); + await chmod(unreadablePath, 0o600); + await session.close(); + }); +}); + +async function createRepositoryWithCommit() { + 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'), 'fixture\n'); + await repository.git('add', 'README.md'); + await repository.git('commit', '--quiet', '-m', 'Initial fixture'); + return repository; +} + +function commandId(value: number) { + return `command_${value.toString(16).padStart(32, '0')}` as ClientCommandId; +} + +async function waitForPath(path: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + await readFile(path); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error('Timed out waiting for the Git filter fixture.'); +}