diff --git a/apps/launcher/src/repository-protocol-adapter.ts b/apps/launcher/src/repository-protocol-adapter.ts index 0be4be0..2c1d86b 100644 --- a/apps/launcher/src/repository-protocol-adapter.ts +++ b/apps/launcher/src/repository-protocol-adapter.ts @@ -119,7 +119,12 @@ function worktree(source: PublishedWorktreeSnapshot, fetchedAt: string | null) { nativeTargets: [nativeFileTarget(source, nativeTargetId, fileId)], }), ), - nativeTargets: [], + nativeTargets: [ + { + targetId: source.nativeTargetId, + actions: ['open_terminal'] as const, + }, + ], }; } diff --git a/apps/launcher/src/standalone-runtime.ts b/apps/launcher/src/standalone-runtime.ts index 441d289..12273d2 100644 --- a/apps/launcher/src/standalone-runtime.ts +++ b/apps/launcher/src/standalone-runtime.ts @@ -81,7 +81,7 @@ export async function startStandaloneRuntime( : { diff: ({ fileId }) => repositorySession!.diff(fileId), nativeActions: (request) => - performFileNativeAction(repositorySession!, request), + performNativeAction(repositorySession!, request), branchSearch: (request) => repositorySession!.searchBranches(request), snapshot: async () => @@ -156,7 +156,12 @@ async function dispatchRepositoryCommand( session: RepositorySession, request: CommandEnvelope, ): Promise { - if (request.command.kind === 'switch_branch') { + if ( + request.command.kind === 'switch_branch' || + request.command.kind === 'pull' || + request.command.kind === 'push' || + request.command.kind === 'publish' + ) { return session.dispatch(request); } if ( @@ -184,10 +189,36 @@ async function dispatchRepositoryCommand( }; } -async function performFileNativeAction( +async function performNativeAction( session: RepositorySession, request: NativeActionRequest, ): Promise { + if (request.kind === 'open_terminal') { + try { + const target = await session.resolveWorktreeNativeTarget( + request.targetId, + ); + const metadata = await lstat(target.worktreePath); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error('The Worktree target is not a directory.'); + } + const resolvedWorktree = await realpath(target.worktreePath); + await execFileAsync( + '/usr/bin/open', + ['-a', 'Terminal', '--', resolvedWorktree], + { + timeout: 10_000, + windowsHide: true, + }, + ); + return { kind: 'performed' }; + } catch { + return { + kind: 'unavailable', + message: 'The Worktree is no longer available. Refresh and try again.', + }; + } + } try { const target = await session.resolveFileNativeTarget(request.targetId); if (request.kind === 'copy_relative_path') { diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 10adf89..b46c3d9 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -20,6 +20,9 @@ const loadingStore = createRepositoryStore({ switchBranch: async () => { throw new Error('Branch switching is unavailable while loading.'); }, + requestRemoteOperation: async () => { + throw new Error('Remote operations are unavailable while loading.'); + }, }); export function App({ diff --git a/apps/ui/src/RepositoryOverview.interactions.test.tsx b/apps/ui/src/RepositoryOverview.interactions.test.tsx index d7215b7..89ca75a 100644 --- a/apps/ui/src/RepositoryOverview.interactions.test.tsx +++ b/apps/ui/src/RepositoryOverview.interactions.test.tsx @@ -25,6 +25,92 @@ describe('Repository overview interactions', () => { container.remove(); }); + it('confirms the exact Remote and same-name target before Publish', async () => { + const fixture = createOverviewFixture('one-worktree'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + fixture.publish({ + kind: 'repository', + snapshot: { + ...current.snapshot, + worktrees: current.snapshot.worktrees.map((worktree) => ({ + ...worktree, + upstream: { + kind: 'unpublished' as const, + remoteName: null, + fetchedAt: null, + }, + })), + }, + }); + const requestRemoteOperation = vi.fn(async () => ({ + kind: 'succeeded' as const, + operationId: operationIdSchema.parse( + 'operation_00000000000000000000000000000002', + ), + result: { + kind: 'remote' as const, + summary: 'Published main to origin.', + }, + })); + const confirm = vi.spyOn(globalThis, 'confirm').mockReturnValue(true); + const store = createRepositoryStore({ + ...fixture.source, + requestRemoteOperation, + }); + act(() => root.render()); + + await act(async () => button('Publish main to origin/main').click()); + + expect(confirm).toHaveBeenCalledWith( + 'Publish Local Branch main to exact target origin/main?', + ); + expect(requestRemoteOperation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'publish', + remoteId: current.snapshot.remotes[0]!.remoteId, + }), + ); + expect(container.textContent).toContain('Published main to origin.'); + }); + + it('routes a diverged Upstream to the exact selected Worktree Terminal target', async () => { + const fixture = createOverviewFixture('one-worktree'); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + fixture.publish({ + kind: 'repository', + snapshot: { + ...current.snapshot, + worktrees: current.snapshot.worktrees.map((worktree) => ({ + ...worktree, + upstream: + worktree.upstream.kind === 'tracking' + ? { ...worktree.upstream, ahead: 1, behind: 1 } + : worktree.upstream, + })), + }, + }); + const requestNativeAction = vi.fn(async () => ({ + kind: 'performed' as const, + })); + const store = createRepositoryStore({ + ...fixture.source, + requestNativeAction, + }); + act(() => root.render()); + + expect(container.textContent).toContain( + 'Open the exact selected Worktree in Terminal to Merge or Rebase explicitly.', + ); + await act(async () => button('Open codex-git in Terminal').click()); + + expect(requestNativeAction).toHaveBeenCalledWith({ + kind: 'open_terminal', + targetId: 'native_00000000000000000000000000000010', + }); + }); + it('reviews Changed Files by group and navigates the current Worktree', async () => { const fixture = createOverviewFixture('changed-worktree'); const store = createRepositoryStore(fixture.source); diff --git a/apps/ui/src/RepositoryOverview.tsx b/apps/ui/src/RepositoryOverview.tsx index a42a2e9..11a6f4a 100644 --- a/apps/ui/src/RepositoryOverview.tsx +++ b/apps/ui/src/RepositoryOverview.tsx @@ -4,7 +4,10 @@ import type { RepositoryOverviewSnapshot, WorktreeOverviewSnapshot, } from './repository-overview-model.js'; -import type { RepositoryStore } from './repository-store.js'; +import type { + RemoteOperationState, + RepositoryStore, +} from './repository-store.js'; import { ChangeGroups } from './ChangeGroups.js'; import { DiffReview } from './DiffReview.js'; @@ -106,6 +109,11 @@ export function RepositoryOverview({ const selected = snapshot.worktrees.find( (worktree) => worktree.worktreeId === state.selectedWorktreeId, ); + const selectedBranchName = + selected?.head.kind === 'local_branch' ? selected.head.displayName : null; + const selectedTerminalTarget = selected?.nativeTargets.find(({ actions }) => + actions.includes('open_terminal'), + ); const unavailableCount = snapshot.worktrees.filter( (worktree) => worktree.availability?.kind === 'unavailable' || @@ -362,14 +370,100 @@ export function RepositoryOverview({ > Switch Branch - + {selected.upstream.kind === 'tracking' ? ( + <> + + + {selected.status.kind === 'changed' ? ( + + Uncommitted content stays local and is not included in + Push. + + ) : null} + + ) : selected.upstream.kind === 'unpublished' && + selectedBranchName !== null ? ( + snapshot.remotes.map((remote) => { + const target = `${remote.displayName}/${selectedBranchName}`; + return ( + + ); + }) + ) : null} + {selected.upstream.kind === 'tracking' && + (selected.upstream.ahead ?? 0) > 0 && + (selected.upstream.behind ?? 0) > 0 ? ( +
+

+ This Local Branch and its Upstream diverged. Open the exact + selected Worktree in Terminal to Merge or Rebase explicitly. +

+ {selectedTerminalTarget === undefined ? null : ( + + )} +
+ ) : null} + {state.remoteOperation.kind === 'idle' ? null : ( +

+ {remoteOperationLabel(state.remoteOperation)} +

+ )} {branchPicker.kind === 'closed' ? null : (

Switch Branch

@@ -550,6 +644,84 @@ function branchSwitchAllowed( ); } +function pullAllowed( + worktree: WorktreeOverviewSnapshot, + operations: RepositoryOverviewSnapshot['operations'], +): boolean { + return ( + worktree.head.kind === 'local_branch' && + worktree.upstream.kind === 'tracking' && + worktree.upstream.ahead === 0 && + (worktree.upstream.behind ?? 0) > 0 && + worktree.status.kind === 'clean' && + worktree.freshness.kind === 'current' && + !remoteOperationActive(operations) + ); +} + +function pushAllowed( + worktree: WorktreeOverviewSnapshot, + operations: RepositoryOverviewSnapshot['operations'], +): boolean { + return ( + worktree.head.kind === 'local_branch' && + worktree.upstream.kind === 'tracking' && + worktree.upstream.behind === 0 && + worktree.upstream.ahead !== null && + worktree.freshness.kind === 'current' && + statusAllowsRemoteWrite(worktree.status) && + !remoteOperationActive(operations) + ); +} + +function publishAllowed( + worktree: WorktreeOverviewSnapshot, + operations: RepositoryOverviewSnapshot['operations'], +): boolean { + return ( + worktree.head.kind === 'local_branch' && + worktree.upstream.kind === 'unpublished' && + worktree.freshness.kind === 'current' && + statusAllowsRemoteWrite(worktree.status) && + !remoteOperationActive(operations) + ); +} + +function statusAllowsRemoteWrite(status: WorktreeOverviewSnapshot['status']) { + return ( + status.kind === 'clean' || + (status.kind === 'changed' && status.conflictCount === 0) + ); +} + +function remoteOperationActive( + operations: RepositoryOverviewSnapshot['operations'], +) { + return operations.some( + ({ category, phase }) => + phase !== 'terminal' && + (category === 'fetch' || + category === 'pull' || + category === 'push' || + category === 'publish'), + ); +} + +function remoteOperationLabel(state: RemoteOperationState): string { + if (state.kind === 'idle') return ''; + if (state.kind === 'running') { + return `${state.operation[0]!.toLocaleUpperCase()}${state.operation.slice(1)} in progress…`; + } + if (state.kind === 'failed') return state.message; + const result = state.result; + if (result.kind === 'succeeded') { + return result.result.kind === 'remote' + ? result.result.summary + : 'The Remote is already up to date.'; + } + return result.message; +} + function compareWorktrees( left: WorktreeOverviewSnapshot, right: WorktreeOverviewSnapshot, diff --git a/apps/ui/src/overview-fixtures.ts b/apps/ui/src/overview-fixtures.ts index 4c3a2c2..5f0e389 100644 --- a/apps/ui/src/overview-fixtures.ts +++ b/apps/ui/src/overview-fixtures.ts @@ -112,6 +112,11 @@ function createMutableFixture( async switchBranch() { throw new Error('Branch switching is not configured for this fixture.'); }, + async requestRemoteOperation() { + throw new Error( + 'Remote operations are not configured for this fixture.', + ); + }, }, publish(nextState) { state = nextState; @@ -144,6 +149,9 @@ const changedFileIds = [1, 2, 3, 4].map((index) => const changedNativeTargetIds = [1, 2, 3, 4].map((index) => nativeTargetIdSchema.parse(`native_${index.toString(16).padStart(32, '0')}`), ); +const worktreeNativeTargetId = nativeTargetIdSchema.parse( + 'native_00000000000000000000000000000010', +); export const oneWorktree: RepositoryOverviewSnapshot = { repositoryId, @@ -172,6 +180,9 @@ export const oneWorktree: RepositoryOverviewSnapshot = { }, status: { kind: 'clean' }, changes: [], + nativeTargets: [ + { targetId: worktreeNativeTargetId, actions: ['open_terminal'] }, + ], upstream: { kind: 'tracking', displayName: 'origin/main', @@ -281,6 +292,14 @@ const linkedWorktrees: RepositoryOverviewSnapshot['worktrees'] = Array.from( } : { kind: 'clean' as const }, changes: [], + nativeTargets: [ + { + targetId: nativeTargetIdSchema.parse( + `native_${(index + 16).toString(16).padStart(32, '0')}`, + ), + actions: ['open_terminal'] as const, + }, + ], upstream: { kind: 'tracking' as const, displayName: `origin/feat/${displayName}`, diff --git a/apps/ui/src/protocol-repository-source.test.ts b/apps/ui/src/protocol-repository-source.test.ts index 70ab647..83ce64a 100644 --- a/apps/ui/src/protocol-repository-source.test.ts +++ b/apps/ui/src/protocol-repository-source.test.ts @@ -393,6 +393,66 @@ describe('ProtocolRepositorySource', () => { 'snapshot', ]); }); + + it('submits typed Push intent and recovers the sanitized outcome', async () => { + let commandBody: unknown; + const remoteResult = { + kind: 'succeeded', + operationId: operationReceipt.operationId, + result: { kind: 'remote', summary: 'Pushed dev.' }, + }; + 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, + commands: true, + operationRecovery: true, + }, + }); + } + if (url.endsWith('/commands')) { + commandBody = JSON.parse(String(init?.body)); + return jsonResponse({ + ...operationReceipt, + clientCommandId: (commandBody as { clientCommandId: string }) + .clientCommandId, + }); + } + if (url.endsWith('/operations')) return jsonResponse(remoteResult); + return jsonResponse(repositorySnapshot); + }, + }); + await until(() => source.getSnapshot().kind === 'repository'); + + const result = await source.requestRemoteOperation({ + kind: 'push', + worktreeId: worktreeIdSchema.parse( + repositorySnapshot.worktrees[0]!.worktreeId, + ), + expectedWorktreeRevision: + repositorySnapshot.worktrees[0]!.worktreeRevision, + expectedRefsRevision: repositorySnapshot.refsRevision, + }); + + expect(commandBody).toEqual({ + clientCommandId: expect.stringMatching(/^command_[0-9a-f]{32}$/u), + command: { + kind: 'push', + worktreeId: repositorySnapshot.worktrees[0]!.worktreeId, + expectedWorktreeRevision: + repositorySnapshot.worktrees[0]!.worktreeRevision, + expectedRefsRevision: repositorySnapshot.refsRevision, + }, + }); + expect(result).toEqual(remoteResult); + }); }); class FakeEventSource { diff --git a/apps/ui/src/protocol-repository-source.ts b/apps/ui/src/protocol-repository-source.ts index 37c77b5..3494f74 100644 --- a/apps/ui/src/protocol-repository-source.ts +++ b/apps/ui/src/protocol-repository-source.ts @@ -287,9 +287,51 @@ export function createProtocolRepositorySource( await requestSnapshot(); return result; }, + async requestRemoteOperation(request) { + const command: ProductCommand = + request.kind === 'publish' + ? { + kind: request.kind, + worktreeId: request.worktreeId, + expectedWorktreeRevision: request.expectedWorktreeRevision, + expectedRefsRevision: request.expectedRefsRevision, + remoteId: requirePublishRemote(request.remoteId), + } + : { + kind: request.kind, + worktreeId: request.worktreeId, + expectedWorktreeRevision: request.expectedWorktreeRevision, + expectedRefsRevision: request.expectedRefsRevision, + }; + const submitted = await protocolPost( + fetcher, + endpointUrl(options.sessionUrl, 'commands'), + { clientCommandId: createClientCommandId(), command }, + ); + if (!submitted.ok) throw new Error('Remote operation submission failed.'); + const receipt = operationReceiptSchema.parse(await submitted.json()); + const recovery = await protocolPost( + fetcher, + endpointUrl(options.sessionUrl, 'operations'), + { operationId: receipt.operationId }, + ); + if (!recovery.ok) throw new Error('Remote operation recovery failed.'); + const result = operationResultSchema.parse(await recovery.json()); + await requestSnapshot(); + return result; + }, }; } +function requirePublishRemote( + remoteId: import('@codex-git/protocol').RemoteId | undefined, +) { + if (remoteId === undefined) { + throw new Error('Publish requires an exact Remote target.'); + } + return remoteId; +} + function requiresSnapshot( state: RepositoryOverviewSourceState, data: string, diff --git a/apps/ui/src/repository-overview-model.ts b/apps/ui/src/repository-overview-model.ts index cc99497..3bb4c5c 100644 --- a/apps/ui/src/repository-overview-model.ts +++ b/apps/ui/src/repository-overview-model.ts @@ -6,6 +6,7 @@ import type { BranchSearchResult, OperationResult, RefId, + RemoteId, RepositorySnapshot, } from '@codex-git/protocol'; @@ -56,6 +57,7 @@ export interface WorktreeOverviewSnapshot { readonly head: ProtocolWorktree['head']; readonly status: ProtocolWorktree['status']; readonly changes: ProtocolWorktree['changes']; + readonly nativeTargets: ProtocolWorktree['nativeTargets']; readonly upstream: UpstreamOverview; readonly transition?: { readonly label: string; @@ -121,4 +123,11 @@ export interface RepositoryOverviewSource { readonly expectedRefsRevision: number; readonly refId: RefId; }): Promise; + requestRemoteOperation(request: { + readonly kind: 'pull' | 'push' | 'publish'; + readonly worktreeId: ProtocolWorktree['worktreeId']; + readonly expectedWorktreeRevision: number; + readonly expectedRefsRevision: number; + readonly remoteId?: RemoteId; + }): Promise; } diff --git a/apps/ui/src/repository-store.test.ts b/apps/ui/src/repository-store.test.ts index ac8b87c..da75f9d 100644 --- a/apps/ui/src/repository-store.test.ts +++ b/apps/ui/src/repository-store.test.ts @@ -6,6 +6,39 @@ import type { RepositoryOverviewSource } from './repository-overview-model.js'; import { createRepositoryStore } from './repository-store.js'; describe('RepositoryStore lifecycle', () => { + it('submits Push with the selected Worktree and observed revisions', async () => { + const fixture = createOverviewFixture('one-worktree'); + const requestRemoteOperation = vi.fn(async () => ({ + kind: 'succeeded' as const, + operationId: operationIdSchema.parse( + 'operation_00000000000000000000000000000001', + ), + result: { kind: 'no_change' as const }, + })); + const store = createRepositoryStore({ + ...fixture.source, + requestRemoteOperation, + }); + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + const worktree = current.snapshot.worktrees[0]!; + + store.push(); + await vi.waitFor(() => expect(requestRemoteOperation).toHaveBeenCalled()); + + expect(requestRemoteOperation).toHaveBeenCalledWith({ + kind: 'push', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: current.snapshot.refsRevision, + remoteId: undefined, + }); + expect(store.getSnapshot().remoteOperation).toMatchObject({ + kind: 'result', + result: { kind: 'succeeded', result: { kind: 'no_change' } }, + }); + }); + it('ignores a late Diff after a newer file is selected', async () => { const fixture = createOverviewFixture('changed-worktree'); const pending = new Map void>(); diff --git a/apps/ui/src/repository-store.ts b/apps/ui/src/repository-store.ts index 66aba23..903c84f 100644 --- a/apps/ui/src/repository-store.ts +++ b/apps/ui/src/repository-store.ts @@ -6,6 +6,7 @@ import type { NativeActionResult, OperationResult, RefId, + RemoteId, WorktreeId, } from '@codex-git/protocol'; @@ -36,6 +37,18 @@ export type BranchPickerState = readonly message: string; }; +export type RemoteOperationState = + | { readonly kind: 'idle' } + | { + readonly kind: 'running'; + readonly operation: 'pull' | 'push' | 'publish'; + } + | { + readonly kind: 'result'; + readonly result: import('@codex-git/protocol').OperationResult; + } + | { readonly kind: 'failed'; readonly message: string }; + import type { RepositoryOverviewSnapshot, RepositoryOverviewSource, @@ -53,6 +66,7 @@ export interface RepositoryStoreSnapshot { readonly selectionNotice: string | null; readonly focusRecoveryRevision: number; readonly branchPicker: BranchPickerState; + readonly remoteOperation: RemoteOperationState; readonly fileMutationResult: OperationResult | null; } @@ -77,6 +91,9 @@ export interface RepositoryStore { closeBranchPicker(): void; setBranchQuery(query: string): void; switchBranch(refId: RefId): void; + pull(): void; + push(): void; + publish(remoteId: RemoteId): void; } export function createRepositoryStore( @@ -97,6 +114,7 @@ export function createRepositoryStore( let focusRecoveryRevision = 0; let branchPicker: BranchPickerState = { kind: 'closed' }; let branchRequestGeneration = 0; + let remoteOperation: RemoteOperationState = { kind: 'idle' }; let fileMutationResult: OperationResult | null = null; let fileFollow: | { readonly displayPath: string; readonly kind: 'stage' | 'unstage' } @@ -365,8 +383,61 @@ export function createRepositoryStore( emit(); }); }, + pull() { + void runRemoteOperation('pull'); + }, + push() { + void runRemoteOperation('push'); + }, + publish(remoteId) { + void runRemoteOperation('publish', remoteId); + }, }; + async function runRemoteOperation( + kind: 'pull' | 'push' | 'publish', + remoteId?: RemoteId, + ) { + if ( + disposed || + remoteOperation.kind === 'running' || + sourceState.kind !== 'repository' || + selectedWorktreeId === null + ) { + return; + } + const worktree = sourceState.snapshot.worktrees.find( + (candidate) => candidate.worktreeId === selectedWorktreeId, + ); + if ( + worktree === undefined || + (kind === 'publish' && remoteId === undefined) + ) { + return; + } + remoteOperation = { kind: 'running', operation: kind }; + emit(); + try { + const result = await source.requestRemoteOperation({ + kind, + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: sourceState.snapshot.refsRevision, + remoteId, + }); + if (disposed) return; + remoteOperation = { kind: 'result', result }; + emit(); + } catch { + if (disposed) return; + remoteOperation = { + kind: 'failed', + message: 'The Remote operation could not be submitted.', + }; + emit(); + } + } + async function loadBranches(query: string) { const worktreeId = selectedWorktreeId; if (worktreeId === null) return; @@ -418,6 +489,7 @@ export function createRepositoryStore( selectionNotice, focusRecoveryRevision, branchPicker, + remoteOperation, fileMutationResult, }; } diff --git a/packages/repository-engine/src/index.ts b/packages/repository-engine/src/index.ts index 4980c9f..015c7a6 100644 --- a/packages/repository-engine/src/index.ts +++ b/packages/repository-engine/src/index.ts @@ -33,5 +33,6 @@ export { type RemoteFetchResult, type RepositoryFetchRequest, type RepositorySession, + type WorktreeNativeTarget, RepositoryTargetFailure, } from './repository-session.js'; diff --git a/packages/repository-engine/src/observation-publication.ts b/packages/repository-engine/src/observation-publication.ts index 78e145e..a06235f 100644 --- a/packages/repository-engine/src/observation-publication.ts +++ b/packages/repository-engine/src/observation-publication.ts @@ -29,6 +29,7 @@ export interface PublishedObservationWorktree extends Omit< DiscoveredWorktree, 'canonicalPathBytes' | 'privateIdentityEvidence' > { + readonly nativeTargetId: NativeTargetId; readonly [privateWorktreeIdentityEvidence]?: string; readonly worktreeRevision: number; readonly freshness: WorktreeFreshness; @@ -111,7 +112,7 @@ export function publishObservedFacts( : publishWorktreeObservation(worktree, observed, prior); const candidate: Omit< PublishedObservationWorktree, - 'worktreeRevision' | 'changes' + 'nativeTargetId' | 'worktreeRevision' | 'changes' > & { readonly changes: readonly ChangedFileObservation[] } = { worktreeId: worktree.worktreeId, generation: worktree.generation, @@ -144,6 +145,10 @@ export function publishObservedFacts( ); return { ...candidate, + nativeTargetId: + prior?.generation === worktree.generation + ? prior.nativeTargetId + : requireNativeTargetIdIssuer(issueNativeTargetId)(), changes, worktreeRevision: prior === undefined ? 1 : prior.worktreeRevision + (changed ? 1 : 0), diff --git a/packages/repository-engine/src/remote-operation.test.ts b/packages/repository-engine/src/remote-operation.test.ts new file mode 100644 index 0000000..76a72af --- /dev/null +++ b/packages/repository-engine/src/remote-operation.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AbsolutePath } from '@codex-git/protocol'; + +import { createRemoteOperationExecutor } from './remote-operation.js'; + +describe('Remote Operation recipes', () => { + it('Pull uses only explicit fast-forward integration from the exact Branch in the Remote', async () => { + const execute = vi.fn(async () => ({ + kind: 'exited' as const, + exitCode: 0, + stderr: '', + })); + const executeRemoteOperation = createRemoteOperationExecutor(execute, { + PATH: '/fixture/bin', + HOME: '/fixture/home', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: 'exfiltrate', + }); + const signal = new AbortController().signal; + + await executeRemoteOperation( + { + kind: 'pull', + worktreePath: '/worktrees/selected' as AbsolutePath, + remoteName: '-origin', + remoteBranchRef: 'refs/heads/team/feature', + }, + signal, + ); + + expect(execute).toHaveBeenCalledWith( + { + args: [ + '-C', + '/worktrees/selected', + 'pull', + '--ff-only', + '--no-rebase', + '--no-tags', + '--', + '-origin', + 'refs/heads/team/feature', + ], + environment: { + PATH: '/fixture/bin', + HOME: '/fixture/home', + GIT_OPTIONAL_LOCKS: '0', + LC_ALL: 'C', + }, + maximumOutputBytes: 4 * 1_024 * 1_024, + }, + signal, + ); + }); + + it('Push uses one exact full-ref refspec without force, tags, deletion, or matching refs', async () => { + let args: readonly string[] | undefined; + const execute = vi.fn( + async (recipe: { readonly args: readonly string[] }) => { + args = recipe.args; + return { kind: 'exited' as const, exitCode: 0, stderr: '' }; + }, + ); + const executeRemoteOperation = createRemoteOperationExecutor(execute); + const signal = new AbortController().signal; + + await executeRemoteOperation( + { + kind: 'push', + worktreePath: '/worktrees/selected' as AbsolutePath, + remoteName: 'origin', + localBranchRef: 'refs/heads/local', + destinationRef: 'refs/heads/exact-target', + }, + signal, + ); + + expect(args).toEqual([ + '-C', + '/worktrees/selected', + 'push', + '--porcelain', + '--', + 'origin', + 'refs/heads/local:refs/heads/exact-target', + ]); + expect(args?.join(' ')).not.toMatch(/force|tags|delete/u); + }); + + it('refreshes only the exact configured Remote-tracking mapping for reconciliation', async () => { + let args: readonly string[] | undefined; + const executeRemoteOperation = createRemoteOperationExecutor( + async (recipe) => { + args = recipe.args; + return { kind: 'exited', exitCode: 0, stderr: '' }; + }, + ); + + await executeRemoteOperation( + { + kind: 'refresh_tracking', + worktreePath: '/worktrees/selected' as AbsolutePath, + remoteName: 'origin', + remoteBranchRef: 'refs/heads/source', + trackingRef: 'refs/remotes/origin/alias', + }, + new AbortController().signal, + ); + + expect(args).toEqual([ + '-C', + '/worktrees/selected', + 'fetch', + '--no-tags', + '--no-prune', + '--', + 'origin', + 'refs/heads/source:refs/remotes/origin/alias', + ]); + expect(args?.join(' ')).not.toContain('+'); + }); + + it.each(['output_limit', 'process_error'] as const)( + 'keeps an ambiguous executor %s outcome unknown', + async (reason) => { + const executeRemoteOperation = createRemoteOperationExecutor( + async () => ({ + kind: 'ambiguous', + reason, + }), + ); + + const result = await executeRemoteOperation( + { + kind: 'push', + worktreePath: '/worktrees/selected' as AbsolutePath, + remoteName: 'origin', + localBranchRef: 'refs/heads/main', + destinationRef: 'refs/heads/main', + }, + new AbortController().signal, + ); + + expect(result).toEqual({ + kind: 'unknown', + message: 'Git did not report an unambiguous Remote Operation outcome.', + }); + }, + ); + + it.each([ + ['fatal: Authentication failed for user:secret', 'authentication'], + ['remote: error: GH006: Protected branch update failed', 'policy'], + ['fatal: Permission denied (publickey)', 'authentication'], + ['remote: permission denied by repository owner', 'permission'], + ['! [rejected] main -> main (non-fast-forward)', 'non_fast_forward'], + ['fatal: unable to access URL: Could not resolve host', 'offline'], + ] as const)( + 'classifies and sanitizes Remote failure %s', + async (stderr, code) => { + const executeRemoteOperation = createRemoteOperationExecutor( + async () => ({ + kind: 'exited', + exitCode: 1, + stderr, + }), + ); + + const result = await executeRemoteOperation( + { + kind: 'push', + worktreePath: '/worktrees/selected' as AbsolutePath, + remoteName: 'origin', + localBranchRef: 'refs/heads/main', + destinationRef: 'refs/heads/main', + }, + new AbortController().signal, + ); + + expect(result).toMatchObject({ kind: 'failed_known', code }); + expect(JSON.stringify(result)).not.toMatch(/secret|private|user:/u); + }, + ); + + it('keeps an unclassified transport failure unknown and sanitized', async () => { + const executeRemoteOperation = createRemoteOperationExecutor(async () => ({ + kind: 'exited', + exitCode: 1, + stderr: 'fatal: secret_token=private unexpected transport failure', + })); + + const result = await executeRemoteOperation( + { + kind: 'push', + worktreePath: '/worktrees/selected' as AbsolutePath, + remoteName: 'origin', + localBranchRef: 'refs/heads/main', + destinationRef: 'refs/heads/main', + }, + new AbortController().signal, + ); + + expect(result).toEqual({ + kind: 'unknown', + message: 'Git did not report an unambiguous Remote Operation outcome.', + }); + expect(JSON.stringify(result)).not.toMatch(/secret|private/u); + }); +}); diff --git a/packages/repository-engine/src/remote-operation.ts b/packages/repository-engine/src/remote-operation.ts new file mode 100644 index 0000000..0917478 --- /dev/null +++ b/packages/repository-engine/src/remote-operation.ts @@ -0,0 +1,214 @@ +import { execFile } from 'node:child_process'; + +import type { AbsolutePath, OperationFailureCode } from '@codex-git/protocol'; + +import { createGitEnvironment } from './git-environment.js'; + +const GIT_OUTPUT_LIMIT_BYTES = 4 * 1_024 * 1_024; + +export type RemoteOperationRequest = + | { + readonly kind: 'pull'; + readonly worktreePath: AbsolutePath; + readonly remoteName: string; + readonly remoteBranchRef: string; + } + | { + readonly kind: 'push'; + readonly worktreePath: AbsolutePath; + readonly remoteName: string; + readonly localBranchRef: string; + readonly destinationRef: string; + } + | { + readonly kind: 'refresh_tracking'; + readonly worktreePath: AbsolutePath; + readonly remoteName: string; + readonly remoteBranchRef: string; + readonly trackingRef: string; + }; + +export type RemoteOperationResult = + | { readonly kind: 'completed' } + | { + readonly kind: 'failed_known'; + readonly code: OperationFailureCode; + readonly message: string; + } + | { + readonly kind: 'unknown'; + readonly message: string; + }; + +export interface RemoteOperationRecipe { + readonly args: readonly string[]; + readonly environment: NodeJS.ProcessEnv; + readonly maximumOutputBytes: number; +} + +export type RemoteOperationExecution = + | { + readonly kind: 'exited'; + readonly exitCode: number; + readonly stderr: string; + } + | { + readonly kind: 'ambiguous'; + readonly reason: 'output_limit' | 'process_error'; + }; + +export type RemoteOperationProcessExecutor = ( + recipe: RemoteOperationRecipe, + signal: AbortSignal, +) => Promise; + +export function createRemoteOperationExecutor( + execute: RemoteOperationProcessExecutor = executeSystemGit, + sourceEnvironment: NodeJS.ProcessEnv = process.env, +) { + const environment = createGitEnvironment(sourceEnvironment); + return async ( + request: RemoteOperationRequest, + signal: AbortSignal, + ): Promise => { + const args = + request.kind === 'pull' + ? [ + '-C', + request.worktreePath, + 'pull', + '--ff-only', + '--no-rebase', + '--no-tags', + '--', + request.remoteName, + request.remoteBranchRef, + ] + : request.kind === 'push' + ? [ + '-C', + request.worktreePath, + 'push', + '--porcelain', + '--', + request.remoteName, + `${request.localBranchRef}:${request.destinationRef}`, + ] + : [ + '-C', + request.worktreePath, + 'fetch', + '--no-tags', + '--no-prune', + '--', + request.remoteName, + `${request.remoteBranchRef}:${request.trackingRef}`, + ]; + const execution = await execute( + { args, environment, maximumOutputBytes: GIT_OUTPUT_LIMIT_BYTES }, + signal, + ); + if (execution.kind === 'ambiguous') { + return { + kind: 'unknown', + message: 'Git did not report an unambiguous Remote Operation outcome.', + }; + } + return execution.exitCode === 0 + ? { kind: 'completed' } + : classifyRemoteOperationFailure(execution.stderr); + }; +} + +const executeSystemGit: RemoteOperationProcessExecutor = (recipe, signal) => + new Promise((resolvePromise, reject) => { + execFile( + 'git', + [...recipe.args], + { + encoding: 'utf8', + env: recipe.environment, + maxBuffer: recipe.maximumOutputBytes, + signal, + windowsHide: true, + }, + (error, _stdout, stderr) => { + if (error === null) { + resolvePromise({ kind: 'exited', exitCode: 0, stderr }); + return; + } + if (signal.aborted) { + reject(error); + return; + } + if (typeof error.code === 'number') { + resolvePromise({ kind: 'exited', exitCode: error.code, stderr }); + return; + } + resolvePromise({ + kind: 'ambiguous', + reason: + error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + ? 'output_limit' + : 'process_error', + }); + }, + ); + }); + +function classifyRemoteOperationFailure(stderr: string): RemoteOperationResult { + const diagnostic = stderr.toLowerCase(); + if ( + /authentication failed|could not read username|publickey|terminal prompts disabled/u.test( + diagnostic, + ) + ) { + return failure('authentication', 'Authentication with the Remote failed.'); + } + if ( + /protected branch|pre-receive hook declined|repository rule/u.test( + diagnostic, + ) + ) { + return failure( + 'policy', + 'The policy for the Branch in the Remote rejected the update.', + ); + } + if (/permission denied|not permitted|access denied/u.test(diagnostic)) { + return failure('permission', 'The Remote denied permission.'); + } + if ( + /non-fast-forward|fetch first|failed to push some refs|not possible to fast-forward/u.test( + diagnostic, + ) + ) { + return failure( + 'non_fast_forward', + 'The Remote rejected a non-fast-forward update.', + ); + } + if ( + /could not resolve host|connection refused|network is unreachable|failed to connect|unable to access/u.test( + diagnostic, + ) + ) { + return failure('offline', 'The Remote could not be reached.'); + } + if ( + /does not appear to be a git repository|no such remote/u.test(diagnostic) + ) { + return failure('invalid_remote', 'The configured Remote is invalid.'); + } + return { + kind: 'unknown', + message: 'Git did not report an unambiguous Remote Operation outcome.', + }; +} + +function failure( + code: OperationFailureCode, + message: string, +): RemoteOperationResult { + return { kind: 'failed_known', code, message }; +} diff --git a/packages/repository-engine/src/repository-engine.ts b/packages/repository-engine/src/repository-engine.ts index c32d8a3..e5d9baf 100644 --- a/packages/repository-engine/src/repository-engine.ts +++ b/packages/repository-engine/src/repository-engine.ts @@ -32,6 +32,7 @@ import { type RepositorySession, } from './repository-session.js'; import { createRemoteFetcher } from './remote-fetch.js'; +import { createRemoteOperationExecutor } from './remote-operation.js'; import { cloneRemoteIdentityState, createRemoteIdentityState, @@ -42,6 +43,7 @@ const GIT_OUTPUT_LIMIT_BYTES = 4 * 1_024 * 1_024; const GIT_TIMEOUT_MILLISECONDS = 10_000; const ZERO_OBJECT_ID = /^(?:0{40}|0{64})$/u; const fetchRemote = createRemoteFetcher(); +const executeRemoteOperation = createRemoteOperationExecutor(); export interface RepositoryDiscovery { readonly repositoryId: RepositoryId; @@ -238,6 +240,7 @@ export function createRepositoryEngine(): RepositoryEngine { readChangedFileDiff(worktree, fileId, runGit), inspectFileMutationTargets: createFileMutationInspector(runGit), runGit, + executeRemoteOperation, }), ); }, diff --git a/packages/repository-engine/src/repository-publication.ts b/packages/repository-engine/src/repository-publication.ts index 387aab8..270fd76 100644 --- a/packages/repository-engine/src/repository-publication.ts +++ b/packages/repository-engine/src/repository-publication.ts @@ -14,6 +14,7 @@ import type { RemoteId, WorktreeId, } from '@codex-git/protocol'; +import { createOpaqueIdAuthority } from '@codex-git/protocol'; import type { OperationSessionSummary } from './operation-session.js'; export interface RepositorySnapshot @@ -281,7 +282,15 @@ export function publishDiscovery( previous?: RepositorySnapshot, observation?: RepositoryObservation, ): RepositorySnapshot { - return publishCandidate(discovery, previous, observation).snapshot; + const ids = createOpaqueIdAuthority(); + return publishCandidate( + discovery, + previous, + observation, + undefined, + undefined, + () => ids.issue('native'), + ).snapshot; } function publishCandidate( diff --git a/packages/repository-engine/src/repository-refresh.ts b/packages/repository-engine/src/repository-refresh.ts index 7a72544..82f83db 100644 --- a/packages/repository-engine/src/repository-refresh.ts +++ b/packages/repository-engine/src/repository-refresh.ts @@ -214,6 +214,8 @@ export function createRefreshingRepositorySession( diff: (fileId) => delegate.diff(fileId), resolveFileNativeTarget: (targetId) => delegate.resolveFileNativeTarget(targetId), + resolveWorktreeNativeTarget: (targetId) => + delegate.resolveWorktreeNativeTarget(targetId), searchBranches: (request) => delegate.searchBranches(request), dispatch, cancelOperation: (operationId) => delegate.cancelOperation(operationId), diff --git a/packages/repository-engine/src/repository-session-file-mutation.test.ts b/packages/repository-engine/src/repository-session-file-mutation.test.ts new file mode 100644 index 0000000..88156f9 --- /dev/null +++ b/packages/repository-engine/src/repository-session-file-mutation.test.ts @@ -0,0 +1,474 @@ +import { describe, expect, it } from 'vitest'; + +import type { + ClientCommandId, + FileId, + NativeTargetId, + WorktreeGeneration, + WorktreeId, +} from '@codex-git/protocol'; + +import type { FileMutationInspector } from './file-mutation-inspection.js'; +import { privateWorktreeIdentityEvidence } from './observation-publication.js'; +import type { ScopedRepositoryPublicationSession } from './repository-publication.js'; +import { createRepositorySession } from './repository-session.js'; + +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.test.ts b/packages/repository-engine/src/repository-session.test.ts index b405d3e..e6e568a 100644 --- a/packages/repository-engine/src/repository-session.test.ts +++ b/packages/repository-engine/src/repository-session.test.ts @@ -1,16 +1,12 @@ import { describe, expect, it } from 'vitest'; import type { + AbsolutePath, 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', () => { @@ -60,462 +56,322 @@ describe('Repository File native targets', () => { }); }); -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 = () => ({ +describe('Repository Worktree native targets', () => { + it('resolves only the exact fresh and available Worktree target', async () => { + const targetId = + 'native_00000000000000000000000000000002' as NativeTargetId; + const repository = { kind: 'repository' as const, - repository: fakeRepository(mutated), - }); + repository: { + repositoryId: 'repository_fixture', + repositoryRevision: 1, + topologyRevision: 1, + refsRevision: 1, + refresh: { kind: 'fresh' }, + remotes: [], + operations: [], + worktrees: [ + { + nativeTargetId: targetId, + canonicalPath: '/projects/selected-worktree', + availability: { kind: 'available' }, + freshness: { kind: 'fresh' }, + changes: [], + }, + ], + }, + }; const delegate = { - snapshot: async () => repository(), - requestRefresh: async () => repository(), - requestScopedRefresh: async () => repository(), - async *subscribe() {}, + snapshot: async () => repository, + requestRefresh: async () => repository, + requestScopedRefresh: async () => repository, 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 session = createRepositorySession(delegate); - 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 expect( + session.resolveWorktreeNativeTarget(targetId), + ).resolves.toEqual({ + worktreePath: '/projects/selected-worktree', }); - 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); +describe('Repository Remote operation outcomes', () => { + it('reports Push success when exact refreshed state proves the effect despite an earlier failure diagnostic', async () => { + const beforePush = remoteRepositoryFixture(); + const afterPush = pushedRepositoryFixture(); + let pushed = false; + const delegate = { + snapshot: async () => (pushed ? afterPush : beforePush), + requestRefresh: async () => (pushed ? afterPush : beforePush), + requestScopedRefresh: async () => (pushed ? afterPush : beforePush), + close: async () => undefined, + } as unknown as ScopedRepositoryPublicationSession; const session = createRepositorySession(delegate, { - inspectFileMutationTargets: fakeInspection, - async runGit() { - throw new Error('Process transport disappeared.'); + runGit: async () => new TextEncoder().encode('origin\0refs/heads/main\n'), + executeRemoteOperation: async (request) => { + if (request.kind === 'push') { + return { + kind: 'failed_known', + code: 'offline', + message: 'The Remote could not be reached.', + }; + } + pushed = true; + return { kind: 'completed' }; }, }); - const opened = await session.snapshot(); - if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository.'); const worktree = opened.repository.worktrees[0]!; const receipt = await session.dispatch({ - clientCommandId: commandId(3), + clientCommandId: + 'command_00000000000000000000000000000003' as ClientCommandId, command: { - kind: 'stage', + kind: 'push', worktreeId: worktree.worktreeId, expectedWorktreeRevision: worktree.worktreeRevision, - fileIds: [worktree.changes[0]!.fileId], + expectedRefsRevision: opened.repository.refsRevision, }, }); + const result = await session.recoverOperation(receipt.operationId); - await expect( - session.recoverOperation(receipt.operationId), - ).resolves.toMatchObject({ - kind: 'unknown_outcome', - code: 'reconciliation_incomplete', + expect(result).toMatchObject({ + kind: 'succeeded', + result: { kind: 'remote', summary: 'Pushed main.' }, }); 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(); - }); + it('keeps matching offline diagnostics unknown without refreshed-state proof', async () => { + const repository = remoteRepositoryFixture(); + const delegate = { + snapshot: async () => repository, + requestRefresh: async () => repository, + requestScopedRefresh: async () => repository, + close: async () => undefined, + } as unknown as ScopedRepositoryPublicationSession; const session = createRepositorySession(delegate, { - inspectFileMutationTargets: fakeInspection, - async runGit(args) { - mutated.add(args[2]!); - return new Uint8Array(); - }, + runGit: async () => new TextEncoder().encode('origin\0refs/heads/main\n'), + executeRemoteOperation: async () => ({ + kind: 'failed_known', + code: 'offline', + message: 'The Remote could not be reached.', + }), }); - const opened = await session.snapshot(); - if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository.'); const worktree = opened.repository.worktrees[0]!; const receipt = await session.dispatch({ - clientCommandId: commandId(4), + clientCommandId: + 'command_00000000000000000000000000000004' as ClientCommandId, command: { - kind: 'stage', + kind: 'push', worktreeId: worktree.worktreeId, expectedWorktreeRevision: worktree.worktreeRevision, - fileIds: [worktree.changes[0]!.fileId], + expectedRefsRevision: opened.repository.refsRevision, }, }); + const result = await session.recoverOperation(receipt.operationId); - await expect( - session.recoverOperation(receipt.operationId), - ).resolves.toMatchObject({ + expect(result).toMatchObject({ kind: 'unknown_outcome', code: 'reconciliation_incomplete', + recoveryAvailable: true, }); 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[] = []; + it('keeps an ambiguous Push unknown when exact Remote reconciliation also cannot complete', async () => { + const repository = remoteRepositoryFixture(); + const delegate = { + snapshot: async () => repository, + requestRefresh: async () => repository, + requestScopedRefresh: async () => repository, + close: async () => undefined, + } as unknown as ScopedRepositoryPublicationSession; 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(); - }, + runGit: async () => new TextEncoder().encode('origin\0refs/heads/main\n'), + executeRemoteOperation: async () => ({ + kind: 'unknown', + message: 'Git did not report an unambiguous Remote Operation outcome.', + }), }); - const opened = await session.snapshot(); - if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository.'); const worktree = opened.repository.worktrees[0]!; const receipt = await session.dispatch({ - clientCommandId: commandId(5), + clientCommandId: + 'command_00000000000000000000000000000001' as ClientCommandId, command: { - kind: 'stage', + kind: 'push', 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), + expectedRefsRevision: opened.repository.refsRevision, }, }); 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' }, - ], + kind: 'unknown_outcome', + code: 'reconciliation_incomplete', + recoveryAvailable: true, }); 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(); + it('reconciles exact Remote state after an executor throws', async () => { + const repository = remoteRepositoryFixture(); + const delegate = { + snapshot: async () => repository, + requestRefresh: async () => repository, + requestScopedRefresh: async () => repository, + close: async () => undefined, + } as unknown as ScopedRepositoryPublicationSession; + const requests: string[] = []; + const session = createRepositorySession(delegate, { + runGit: async () => new TextEncoder().encode('origin\0refs/heads/main\n'), + executeRemoteOperation: async (request) => { + requests.push(request.kind); + if (request.kind === 'push') throw new Error('ambiguous process loss'); + return { kind: 'completed' }; }, }); - const opened = await session.snapshot(); - if (opened.kind !== 'repository') throw new Error('Expected Repository'); + const opened = await session.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository.'); const worktree = opened.repository.worktrees[0]!; const receipt = await session.dispatch({ - clientCommandId: commandId(7), + clientCommandId: + 'command_00000000000000000000000000000002' as ClientCommandId, command: { - kind: 'stage', + kind: 'push', worktreeId: worktree.worktreeId, expectedWorktreeRevision: worktree.worktreeRevision, - fileIds: worktree.changes.map(({ fileId }) => fileId), + expectedRefsRevision: opened.repository.refsRevision, }, }); const result = await session.recoverOperation(receipt.operationId); - expect(mutations).toBe(1); + expect(requests[0]).toBe('push'); + expect(requests.slice(1)).not.toHaveLength(0); + expect(requests.slice(1)).toEqual( + requests.slice(1).map(() => 'refresh_tracking'), + ); 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', - }, - ], + kind: 'unknown_outcome', + code: 'reconciliation_incomplete', }); 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(), -) { +function remoteRepositoryFixture() { + const repositoryId = 'repository_00000000000000000000000000000001' as const; + const worktreeId = 'worktree_00000000000000000000000000000001' as const; + const generation = 'generation_00000000000000000000000000000001' as const; + const remoteId = 'remote_00000000000000000000000000000001' as const; + const objectId = '0123456789abcdef0123456789abcdef01234567'; 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')), - ], + kind: 'repository' as const, + repository: { + repositoryId, + repositoryRevision: 1, + topologyRevision: 1, + refsRevision: 1, + refresh: { kind: 'fresh' as const }, + fetch: { kind: 'never' as const }, + operations: [], + commonGitDirectory: '/projects/repository/.git' as AbsolutePath, + selectedWorktreeId: worktreeId, + refs: [ + { kind: 'local' as const, fullName: 'refs/heads/main', objectId }, + { + kind: 'remote_tracking' as const, + fullName: 'refs/remotes/origin/main', + objectId: '1123456789abcdef0123456789abcdef01234567', + }, + ], + remotes: [ + { + remoteId, + displayName: 'origin', + host: 'example.test', + configurationEvidence: 'configured-origin', + }, + ], + worktrees: [ + { + worktreeId, + worktreeRevision: 1, + generation, + displayPath: '/projects/repository', + canonicalPath: '/projects/repository' as AbsolutePath, + role: 'main' as const, + head: { + kind: 'local_branch' as const, + fullName: 'refs/heads/main', + displayName: 'main', + objectId, + }, + gitLock: { kind: 'unlocked' as const }, + availability: { kind: 'available' as const }, + freshness: { kind: 'fresh' as const }, + index: { entryCount: 1, fingerprint: 'index', locked: false }, + status: { + clean: true, + conflicted: 0, + staged: 0, + unstaged: 0, + untracked: 0, + }, + changes: [], + upstream: { + kind: 'tracking' as const, + remoteId, + displayName: 'origin/main', + ref: { + kind: 'remote_tracking' as const, + fullName: 'refs/remotes/origin/main', + objectId: '1123456789abcdef0123456789abcdef01234567', + }, + aheadBehind: { kind: 'cached' as const, ahead: 1, behind: 0 }, + }, + }, + ], + }, }; } -function fakeWorktree( - index: number, - path: string, - staged: boolean, - fileCount = 1, -) { - const suffix = index.toString(16).padStart(32, '0'); +function pushedRepositoryFixture() { + const fixture = remoteRepositoryFixture(); + const localObjectId = fixture.repository.worktrees[0]!.head.objectId!; 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, + ...fixture, + repository: { + ...fixture.repository, + repositoryRevision: fixture.repository.repositoryRevision + 1, + refsRevision: fixture.repository.refsRevision + 1, + refs: fixture.repository.refs.map((ref) => + ref.kind === 'remote_tracking' + ? { ...ref, objectId: localObjectId } + : ref, + ), + worktrees: fixture.repository.worktrees.map((worktree) => ({ + ...worktree, + worktreeRevision: worktree.worktreeRevision + 1, + upstream: { + ...worktree.upstream, + ref: { ...worktree.upstream.ref, objectId: localObjectId }, + aheadBehind: { kind: 'cached' as const, ahead: 0, behind: 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 4ddc39d..2a03e1e 100644 --- a/packages/repository-engine/src/repository-session.ts +++ b/packages/repository-engine/src/repository-session.ts @@ -2,6 +2,7 @@ import { resolve, sep } from 'node:path'; import { createOpaqueIdAuthority, + type AbsolutePath, type BranchSearchRequest, type BranchSearchResult, type CommandEnvelope, @@ -23,6 +24,7 @@ import { type OperationSessionAdmission, type OperationSessionSummary, } from './operation-session.js'; +import type { RemoteOperationResult } from './remote-operation.js'; import type { FileMutationInspector } from './file-mutation-inspection.js'; import type { RepositoryInvalidation, @@ -40,6 +42,9 @@ export interface RepositorySession extends RepositoryPublicationSession { fetch(request: RepositoryFetchRequest): Promise; diff(fileId: FileId): Promise; resolveFileNativeTarget(targetId: NativeTargetId): Promise; + resolveWorktreeNativeTarget( + targetId: NativeTargetId, + ): Promise; searchBranches(request: BranchSearchRequest): Promise; dispatch(request: CommandEnvelope): Promise; cancelOperation(operationId: OperationId): Promise; @@ -67,6 +72,10 @@ export interface RepositorySessionOptions { ) => Promise; readonly now?: () => Date; readonly runGit?: GitProcessRunner; + readonly executeRemoteOperation?: ( + request: import('./remote-operation.js').RemoteOperationRequest, + signal: AbortSignal, + ) => Promise; readonly inspectFileMutationTargets?: FileMutationInspector; readonly diff?: ( worktree: RepositorySnapshot['worktrees'][number], @@ -105,6 +114,10 @@ export interface FileNativeTarget { readonly worktreePath: string; } +export interface WorktreeNativeTarget { + readonly worktreePath: string; +} + export interface InternalRepositorySession extends RepositorySession, ScopedRepositoryPublicationSession {} @@ -145,6 +158,45 @@ type BranchSwitchEvidence = } | { readonly kind: 'attempted'; readonly displayName: string }; +type RemoteCommand = Extract< + CommandEnvelope['command'], + { readonly kind: 'pull' | 'push' | 'publish' } +>; + +type RemoteOperationEvidence = + | { + readonly kind: 'rejected'; + readonly result: Omit< + Extract, + 'operationId' + >; + } + | { readonly kind: 'no_change' } + | { + readonly kind: 'completed'; + readonly branchName: string; + readonly localObjectId: string; + readonly upstreamDisplayName: string; + } + | { + readonly kind: 'failed_known'; + readonly code: OperationFailureCode; + readonly message: string; + } + | { + readonly kind: 'unknown'; + readonly branchName: string; + readonly localObjectId: string; + readonly upstreamDisplayName: string; + } + | { + readonly kind: 'publish_unconfigured'; + readonly branchName: string; + readonly localObjectId: string; + readonly remoteName: string; + readonly trackingRef: string; + }; + export function createRepositorySession( delegate: ScopedRepositoryPublicationSession, options: RepositorySessionOptions = {}, @@ -222,6 +274,678 @@ export function createRepositorySession( }, }); + const reconcileRemoteTracking = async (request: { + readonly worktreePath: AbsolutePath; + readonly remoteName: string; + readonly remoteBranchRef: string; + readonly trackingRef: string; + }): Promise => { + if (options.executeRemoteOperation === undefined) { + return unknownRemoteReconciliation(); + } + try { + return await options.executeRemoteOperation( + { kind: 'refresh_tracking', ...request }, + AbortSignal.timeout(10_000), + ); + } catch { + return unknownRemoteReconciliation(); + } + }; + + const dispatchRemoteCommand = async ( + command: RemoteCommand, + ): Promise => { + const initial = latestBase?.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ); + if (initial === undefined) { + throw new Error('Remote operations require a current Worktree snapshot.'); + } + if (command.kind === 'publish') { + if ( + initial.head.kind !== 'local_branch' || + initial.head.objectId === null || + initial.upstream.kind !== 'unpublished' + ) { + throw new Error('Publish requires an Unpublished Local Branch.'); + } + const initialHead = initial.head; + const initialObjectId = initialHead.objectId; + if (initialObjectId === null) { + throw new Error('Publish requires a committed Local Branch.'); + } + const initialRemote = latestBase?.remotes.find( + ({ remoteId }) => remoteId === command.remoteId, + ); + if (initialRemote === undefined) { + throw new Error('Publish requires an exact configured Remote.'); + } + const destinationRef = initialHead.fullName; + const trackingRef = `refs/remotes/${initialRemote.displayName}/${initialHead.displayName}`; + return operations.dispatch({ + kind: 'publish', + worktreeGeneration: initial.generation, + localBranchRef: initialHead.fullName, + destinationRef, + remoteId: initialRemote.remoteId, + async reconcileBusy() { + await observe(() => delegate.requestRefresh()).catch(() => undefined); + }, + async execute({ signal }): Promise { + const current = await observe(() => delegate.requestRefresh()).catch( + () => undefined, + ); + if (current?.kind !== 'repository') { + return reject('stale', 'The Repository is no longer available.'); + } + const repository = current.repository; + const worktree = repository.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ); + const remote = repository.remotes.find( + ({ remoteId }) => remoteId === command.remoteId, + ); + if ( + repository.refsRevision !== command.expectedRefsRevision || + worktree === undefined || + worktree.worktreeRevision !== command.expectedWorktreeRevision || + worktree.head.kind !== 'local_branch' || + worktree.head.fullName !== initialHead.fullName || + worktree.head.objectId !== initialHead.objectId || + worktree.upstream.kind !== 'unpublished' || + remote?.displayName !== initialRemote.displayName + ) { + return reject( + 'stale', + 'Worktree, Branch, Remote, or publication state changed; refresh and confirm again.', + ); + } + if ( + worktree.availability.kind !== 'available' || + worktree.canonicalPath === null || + worktree.freshness.kind !== 'fresh' || + worktree.status === null || + worktree.status.conflicted !== 0 || + worktree.status.inProgressOperation !== undefined || + worktree.gitLock.kind !== 'unlocked' + ) { + return reject( + 'precondition_failed', + 'Publish requires an available Local Branch with no Conflict or Git operation.', + ); + } + if (options.executeRemoteOperation === undefined) { + return reject( + 'unsupported_state', + 'Publish is unavailable in this Repository Session.', + ); + } + const pushed = await options.executeRemoteOperation( + { + kind: 'push', + worktreePath: worktree.canonicalPath, + remoteName: remote.displayName, + localBranchRef: initialHead.fullName, + destinationRef, + }, + signal, + ); + if (pushed.kind === 'failed_known') return pushed; + const unknownEvidence: Extract< + RemoteOperationEvidence, + { readonly kind: 'unknown' } + > = { + kind: 'unknown', + branchName: initialHead.displayName, + localObjectId: initialObjectId, + upstreamDisplayName: `${remote.displayName}/${initialHead.displayName}`, + }; + const publicationEvidence: Extract< + RemoteOperationEvidence, + { readonly kind: 'publish_unconfigured' } + > = { + kind: 'publish_unconfigured', + branchName: initialHead.displayName, + localObjectId: initialObjectId, + remoteName: remote.displayName, + trackingRef, + }; + const trackingReconciliation = await reconcileRemoteTracking({ + worktreePath: worktree.canonicalPath, + remoteName: remote.displayName, + remoteBranchRef: destinationRef, + trackingRef, + }); + if (trackingReconciliation.kind !== 'completed') { + return unknownEvidence; + } + const pushedState = await observe(() => + delegate.requestRefresh(), + ).catch(() => undefined); + if ( + pushedState?.kind !== 'repository' || + pushedState.repository.refs.find( + ({ fullName }) => fullName === trackingRef, + )?.objectId !== initialHead.objectId + ) { + return publicationEvidence; + } + if (options.runGit === undefined) return publicationEvidence; + try { + await options.runGit( + [ + '-C', + worktree.canonicalPath, + 'branch', + `--set-upstream-to=${trackingRef}`, + '--', + initialHead.displayName, + ], + false, + undefined, + signal, + ); + } catch { + return publicationEvidence; + } + return { + kind: 'completed', + branchName: initialHead.displayName, + localObjectId: initialObjectId, + upstreamDisplayName: `${remote.displayName}/${initialHead.displayName}`, + }; + }, + async reconcile(context) { + const evidence = + context.execution.kind === 'returned' + ? (context.execution.evidence as RemoteOperationEvidence) + : undefined; + if (evidence?.kind === 'rejected') return evidence.result; + const trackingReconciliation = + evidence?.kind === 'no_change' + ? null + : await reconcileRemoteTracking({ + worktreePath: initial.canonicalPath!, + remoteName: initialRemote.displayName, + remoteBranchRef: destinationRef, + trackingRef, + }); + if ( + trackingReconciliation !== null && + trackingReconciliation.kind !== 'completed' + ) { + return unknownRemoteOutcome(); + } + const reconciled = await observe(() => + delegate.requestRefresh(), + ).catch(() => undefined); + if ( + reconciled?.kind !== 'repository' || + reconciled.repository.refresh.kind !== 'fresh' + ) { + return unknownRemoteOutcome(); + } + if (evidence?.kind === 'no_change') { + return { kind: 'succeeded', result: { kind: 'no_change' } }; + } + const worktree = reconciled.repository.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ); + const branchName = initialHead.displayName; + const localObjectId = initialObjectId; + const remoteName = + evidence?.kind === 'publish_unconfigured' + ? evidence.remoteName + : initialRemote.displayName; + const observedTrackingRef = + evidence?.kind === 'publish_unconfigured' + ? evidence.trackingRef + : trackingRef; + const remotePublished = + reconciled.repository.refs.find( + ({ fullName }) => fullName === observedTrackingRef, + )?.objectId === localObjectId; + if ( + remotePublished && + worktree?.head.kind === 'local_branch' && + worktree.head.fullName === initialHead.fullName && + worktree.head.objectId === localObjectId && + worktree.upstream.kind === 'tracking' && + worktree.upstream.remoteId === initialRemote.remoteId && + worktree.upstream.ref.fullName === observedTrackingRef && + worktree.upstream.ref.objectId === localObjectId + ) { + return { + kind: 'succeeded', + result: { + kind: 'remote', + summary: `Published ${branchName} to ${remoteName}.`, + }, + }; + } + if (remotePublished) { + return { + kind: 'partial_success', + message: + 'The Branch was published, but its Upstream was not configured.', + effects: [ + { kind: 'succeeded', label: `Published ${branchName}` }, + { + kind: 'failed_known', + label: 'Configure Upstream', + code: 'process_failed', + message: 'Git could not configure the Local Branch Upstream.', + }, + ], + }; + } + if (evidence?.kind === 'failed_known') return evidence; + if (context.execution.kind !== 'returned' || evidence === undefined) { + return unknownRemoteOutcome(); + } + return unknownRemoteOutcome(); + }, + }); + } + if (command.kind === 'push') { + if ( + initial.head.kind !== 'local_branch' || + initial.upstream.kind !== 'tracking' + ) { + throw new Error('Push requires a Local Branch with an exact Upstream.'); + } + const initialHead = initial.head; + const expectedUpstream = initial.upstream; + const configuredUpstream = await readConfiguredUpstreamTarget( + initial, + options.runGit, + ); + const initialRemote = latestBase?.remotes.find( + ({ remoteId, displayName }) => + remoteId === expectedUpstream.remoteId && + displayName === configuredUpstream?.remoteName, + ); + const destinationRef = configuredUpstream?.mergeRef ?? null; + if (initialRemote === undefined || destinationRef === null) { + throw new Error( + 'Push requires an exact configured Remote destination.', + ); + } + return operations.dispatch({ + kind: 'push', + worktreeGeneration: initial.generation, + localBranchRef: initialHead.fullName, + destinationRef, + remoteId: expectedUpstream.remoteId, + async reconcileBusy() { + await observe(() => delegate.requestRefresh()).catch(() => undefined); + }, + async execute({ signal }): Promise { + const current = await observe(() => delegate.requestRefresh()).catch( + () => undefined, + ); + if (current?.kind !== 'repository') { + return reject('stale', 'The Repository is no longer available.'); + } + const repository = current.repository; + const worktree = repository.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ); + const remote = repository.remotes.find( + ({ remoteId }) => remoteId === expectedUpstream.remoteId, + ); + if ( + repository.refsRevision !== command.expectedRefsRevision || + worktree === undefined || + worktree.worktreeRevision !== command.expectedWorktreeRevision || + worktree.head.kind !== 'local_branch' || + worktree.head.fullName !== initialHead.fullName || + worktree.head.objectId !== initialHead.objectId || + worktree.upstream.kind !== 'tracking' || + worktree.upstream.remoteId !== expectedUpstream.remoteId || + worktree.upstream.ref.fullName !== expectedUpstream.ref.fullName || + worktree.upstream.ref.objectId !== expectedUpstream.ref.objectId || + remote?.displayName !== initialRemote.displayName + ) { + return reject( + 'stale', + 'Worktree, Branch, or Upstream state changed; refresh and try again.', + ); + } + const currentConfiguredUpstream = await readConfiguredUpstreamTarget( + worktree, + options.runGit, + ).catch(() => null); + if ( + currentConfiguredUpstream?.remoteName !== + configuredUpstream?.remoteName || + currentConfiguredUpstream?.mergeRef !== configuredUpstream?.mergeRef + ) { + return reject( + 'stale', + 'The configured Upstream target changed; refresh and try again.', + ); + } + if ( + worktree.availability.kind !== 'available' || + worktree.canonicalPath === null || + worktree.freshness.kind !== 'fresh' || + worktree.status === null || + worktree.status.conflicted !== 0 || + worktree.status.inProgressOperation !== undefined || + worktree.gitLock.kind !== 'unlocked' + ) { + return reject( + 'precondition_failed', + 'Push requires an available Local Branch with no Conflict or Git operation.', + ); + } + if (worktree.upstream.aheadBehind.kind !== 'cached') { + return reject( + 'precondition_failed', + 'Push requires current cached Upstream divergence.', + ); + } + if (worktree.upstream.aheadBehind.behind > 0) { + return reject( + 'precondition_failed', + 'The Local Branch is behind or diverged from its Upstream. Pull or reconcile it first.', + ); + } + if (worktree.upstream.aheadBehind.ahead === 0) { + return { kind: 'no_change' }; + } + if (options.executeRemoteOperation === undefined) { + return reject( + 'unsupported_state', + 'Push is unavailable in this Repository Session.', + ); + } + const result = await options.executeRemoteOperation( + { + kind: 'push', + worktreePath: worktree.canonicalPath, + remoteName: remote.displayName, + localBranchRef: initialHead.fullName, + destinationRef, + }, + signal, + ); + return result.kind === 'completed' + ? { + kind: 'completed', + branchName: initialHead.displayName, + localObjectId: initialHead.objectId!, + upstreamDisplayName: expectedUpstream.displayName, + } + : result.kind === 'failed_known' + ? result + : { + kind: 'unknown', + branchName: initialHead.displayName, + localObjectId: initialHead.objectId!, + upstreamDisplayName: expectedUpstream.displayName, + }; + }, + async reconcile(context) { + const evidence = + context.execution.kind === 'returned' + ? (context.execution.evidence as RemoteOperationEvidence) + : undefined; + if (evidence?.kind === 'rejected') return evidence.result; + const trackingReconciliation = + evidence?.kind === 'no_change' + ? null + : await reconcileRemoteTracking({ + worktreePath: initial.canonicalPath!, + remoteName: initialRemote.displayName, + remoteBranchRef: destinationRef, + trackingRef: expectedUpstream.ref.fullName, + }); + if ( + trackingReconciliation !== null && + trackingReconciliation.kind !== 'completed' + ) { + return unknownRemoteOutcome(); + } + const reconciled = await observe(() => + delegate.requestRefresh(), + ).catch(() => undefined); + if ( + reconciled?.kind !== 'repository' || + reconciled.repository.refresh.kind !== 'fresh' + ) { + return unknownRemoteOutcome(); + } + if (evidence?.kind === 'no_change') { + return { kind: 'succeeded', result: { kind: 'no_change' } }; + } + const worktree = reconciled.repository.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ); + if ( + worktree?.head.kind === 'local_branch' && + worktree.head.fullName === initialHead.fullName && + worktree.head.objectId === initialHead.objectId && + worktree.upstream.kind === 'tracking' && + worktree.upstream.displayName === expectedUpstream.displayName && + worktree.upstream.ref.objectId === initialHead.objectId && + worktree.upstream.aheadBehind.kind === 'cached' && + worktree.upstream.aheadBehind.ahead === 0 && + worktree.upstream.aheadBehind.behind === 0 + ) { + return { + kind: 'succeeded', + result: { + kind: 'remote', + summary: `Pushed ${initialHead.displayName}.`, + }, + }; + } + if (evidence?.kind === 'failed_known') return evidence; + return unknownRemoteOutcome(); + }, + }); + } + if (command.kind !== 'pull') throw new Error('Unsupported Remote command.'); + if (initial.head.kind !== 'local_branch') { + throw new Error('Pull requires a Local Branch.'); + } + const initialHead = initial.head; + if (initial.upstream.kind !== 'tracking') { + throw new Error('Pull requires an exact configured Upstream.'); + } + const expectedUpstream = initial.upstream; + const configuredUpstream = await readConfiguredUpstreamTarget( + initial, + options.runGit, + ); + const initialRemote = latestBase?.remotes.find( + ({ remoteId, displayName }) => + remoteId === expectedUpstream.remoteId && + displayName === configuredUpstream?.remoteName, + ); + if (configuredUpstream === null || initialRemote === undefined) { + throw new Error('Pull requires an exact configured Upstream target.'); + } + return operations.dispatch({ + kind: 'pull', + worktreeGeneration: initial.generation, + localBranchRef: initialHead.fullName, + upstreamRef: configuredUpstream.mergeRef, + remoteId: expectedUpstream.remoteId, + async reconcileBusy() { + await observe(() => delegate.requestRefresh()).catch(() => undefined); + }, + async execute({ signal }): Promise { + const current = await observe(() => delegate.requestRefresh()).catch( + () => undefined, + ); + if (current?.kind !== 'repository') { + return reject('stale', 'The Repository is no longer available.'); + } + const repository = current.repository; + const worktree = repository.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ); + if ( + repository.refsRevision !== command.expectedRefsRevision || + worktree === undefined || + worktree.worktreeRevision !== command.expectedWorktreeRevision || + worktree.head.kind !== 'local_branch' || + worktree.head.fullName !== initialHead.fullName || + worktree.head.objectId !== initialHead.objectId || + worktree.upstream.kind !== 'tracking' || + worktree.upstream.remoteId !== expectedUpstream.remoteId || + worktree.upstream.ref.fullName !== expectedUpstream.ref.fullName || + worktree.upstream.ref.objectId !== expectedUpstream.ref.objectId + ) { + return reject( + 'stale', + 'Worktree, Branch, or Upstream state changed; refresh and try again.', + ); + } + const currentConfiguredUpstream = await readConfiguredUpstreamTarget( + worktree, + options.runGit, + ).catch(() => null); + if ( + currentConfiguredUpstream?.remoteName !== + configuredUpstream.remoteName || + currentConfiguredUpstream?.mergeRef !== configuredUpstream.mergeRef + ) { + return reject( + 'stale', + 'The configured Upstream target changed; refresh and try again.', + ); + } + if ( + worktree.availability.kind !== 'available' || + worktree.canonicalPath === null || + worktree.freshness.kind !== 'fresh' || + worktree.status?.clean !== true || + worktree.status.conflicted !== 0 || + worktree.status.inProgressOperation !== undefined || + worktree.index?.locked !== false || + worktree.gitLock.kind !== 'unlocked' + ) { + return reject( + 'precondition_failed', + 'Pull requires a clean, available Worktree with no Git operation or lock.', + ); + } + if (worktree.upstream.aheadBehind.kind !== 'cached') { + return reject( + 'precondition_failed', + 'Pull requires current cached Upstream divergence.', + ); + } + const { ahead, behind } = worktree.upstream.aheadBehind; + if (ahead > 0 && behind > 0) { + return reject( + 'precondition_failed', + 'The Local Branch and Upstream diverged. Open Terminal to Merge or Rebase explicitly.', + ); + } + if (behind === 0) return { kind: 'no_change' }; + const currentUpstream = worktree.upstream; + const remote = repository.remotes.find( + ({ remoteId, displayName }) => + remoteId === currentUpstream.remoteId && + displayName === configuredUpstream.remoteName, + ); + const remoteBranchRef = configuredUpstream.mergeRef; + if ( + remote === undefined || + remoteBranchRef === null || + options.executeRemoteOperation === undefined + ) { + return reject( + 'unsupported_state', + 'The exact Pull target is unavailable in this Repository Session.', + ); + } + const result = await options.executeRemoteOperation( + { + kind: 'pull', + worktreePath: worktree.canonicalPath, + remoteName: remote.displayName, + remoteBranchRef, + }, + signal, + ); + return result.kind === 'completed' + ? { + kind: 'completed', + branchName: worktree.head.displayName, + localObjectId: worktree.head.objectId!, + upstreamDisplayName: currentUpstream.displayName, + } + : result.kind === 'failed_known' + ? result + : { + kind: 'unknown', + branchName: worktree.head.displayName, + localObjectId: worktree.head.objectId!, + upstreamDisplayName: currentUpstream.displayName, + }; + }, + async reconcile(context) { + const evidence = + context.execution.kind === 'returned' + ? (context.execution.evidence as RemoteOperationEvidence) + : undefined; + if (evidence?.kind === 'rejected') return evidence.result; + if (evidence?.kind === 'no_change') { + await observe(() => delegate.requestRefresh()).catch(() => undefined); + return { kind: 'succeeded', result: { kind: 'no_change' } }; + } + const trackingReconciliation = await reconcileRemoteTracking({ + worktreePath: initial.canonicalPath!, + remoteName: initialRemote.displayName, + remoteBranchRef: configuredUpstream.mergeRef, + trackingRef: expectedUpstream.ref.fullName, + }); + if (trackingReconciliation.kind !== 'completed') { + return unknownRemoteOutcome(); + } + const reconciled = await observe(() => delegate.requestRefresh()).catch( + () => undefined, + ); + if ( + reconciled?.kind !== 'repository' || + reconciled.repository.refresh.kind !== 'fresh' + ) { + return unknownRemoteOutcome(); + } + const worktree = reconciled.repository.worktrees.find( + ({ worktreeId }) => worktreeId === command.worktreeId, + ); + if ( + worktree?.head.kind === 'local_branch' && + worktree.head.fullName === initialHead.fullName && + worktree.upstream.kind === 'tracking' && + worktree.upstream.displayName === expectedUpstream.displayName && + worktree.head.objectId === worktree.upstream.ref.objectId && + worktree.upstream.aheadBehind.kind === 'cached' && + worktree.upstream.aheadBehind.ahead === 0 && + worktree.upstream.aheadBehind.behind === 0 + ) { + return { + kind: 'succeeded', + result: { + kind: 'remote', + summary: `Pulled ${initialHead.displayName}.`, + }, + }; + } + if (evidence?.kind === 'failed_known') return evidence; + return unknownRemoteOutcome(); + }, + }); + }; + return { snapshot: () => observe(() => delegate.snapshot()), requestRefresh: () => observe(() => delegate.requestRefresh()), @@ -544,6 +1268,22 @@ export function createRepositorySession( } throw new RepositoryTargetFailure(); }, + async resolveWorktreeNativeTarget(targetId) { + const result = await observe(() => delegate.requestRefresh()); + if (result.kind !== 'repository') throw new RepositoryTargetFailure(); + const worktree = result.repository.worktrees.find( + (candidate) => candidate.nativeTargetId === targetId, + ); + if ( + worktree === undefined || + worktree.canonicalPath === null || + worktree.availability.kind !== 'available' || + worktree.freshness.kind !== 'fresh' + ) { + throw new RepositoryTargetFailure(); + } + return { worktreePath: worktree.canonicalPath }; + }, async searchBranches(request) { const observed = await observe(() => delegate.requestRefresh()); if (observed.kind !== 'repository') { @@ -623,6 +1363,24 @@ export function createRepositorySession( }; }, async dispatch(request) { + if ( + request.command.kind === 'pull' || + request.command.kind === 'push' || + request.command.kind === 'publish' + ) { + const admission = await dispatchRemoteCommand(request.command); + 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 === 'stage' || request.command.kind === 'unstage' @@ -1378,6 +2136,67 @@ function remoteTrackingLocalName( return fullName.startsWith(prefix) ? fullName.slice(prefix.length) : null; } +interface ConfiguredUpstreamTarget { + readonly remoteName: string; + readonly mergeRef: string; +} + +async function readConfiguredUpstreamTarget( + worktree: RepositorySnapshot['worktrees'][number], + runGit: GitProcessRunner | undefined, +): Promise { + if ( + worktree.head.kind !== 'local_branch' || + worktree.canonicalPath === null || + runGit === undefined + ) { + return null; + } + const output = await runGit( + [ + '-C', + worktree.canonicalPath, + 'for-each-ref', + '--format=%(upstream:remotename)%00%(upstream:remoteref)', + worktree.head.fullName, + ], + false, + ); + const [remoteName = '', mergeRefWithNewline = '', extra] = new TextDecoder( + 'utf-8', + { fatal: true }, + ) + .decode(output) + .split('\0'); + const mergeRef = mergeRefWithNewline.replace(/\r?\n$/u, ''); + if ( + extra !== undefined || + remoteName.length === 0 || + remoteName.includes('\n') || + !mergeRef.startsWith('refs/heads/') || + mergeRef.includes('\n') + ) { + return null; + } + return { remoteName, mergeRef }; +} + +function unknownRemoteOutcome() { + return { + kind: 'unknown_outcome' as const, + code: 'reconciliation_incomplete' as const, + message: 'The Remote Operation could not be reconciled to fresh Git state.', + recoveryAvailable: true as const, + }; +} + +function unknownRemoteReconciliation(): RemoteOperationResult { + return { + kind: 'unknown', + message: 'The exact Remote state could not be refreshed safely.', + }; +} + async function detachedHeadWarning( worktree: RepositorySnapshot['worktrees'][number] | undefined, runGit: GitProcessRunner | undefined, diff --git a/tests/integration/repository-sync.integration.test.ts b/tests/integration/repository-sync.integration.test.ts new file mode 100644 index 0000000..4eedcdd --- /dev/null +++ b/tests/integration/repository-sync.integration.test.ts @@ -0,0 +1,644 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { + AbsolutePath, + ClientCommandId, + ProductCommand, +} from '@codex-git/protocol'; +import { + createRepositoryEngine, + type RepositorySession, + type RepositorySnapshot, +} 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 temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + repositories.splice(0).map((repository) => repository.dispose()), + ); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe('Repository Pull, Push, and Publish', () => { + it('Pull targets branch..merge when the fetch refspec maps to a different tracking name', async () => { + const fixture = await customMappingFixture(); + const producer = await cloneRemoteBranch(fixture.remotePath, 'source'); + await writeFile( + join(producer.path, 'remote.txt'), + 'mapped remote change\n', + ); + await producer.git('add', '--', 'remote.txt'); + await producer.git('commit', '--quiet', '-m', 'Advance mapped source'); + const expectedHead = ( + await producer.git('rev-parse', 'HEAD') + ).stdout.trim(); + await producer.git('push', '--quiet', 'origin', 'HEAD:refs/heads/source'); + await fixture.repository.git('fetch', '--quiet', 'origin'); + const session = await createRepositoryEngine().open( + fixture.repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + expect(worktree.upstream).toMatchObject({ + kind: 'tracking', + displayName: 'origin/alias', + aheadBehind: { kind: 'cached', ahead: 0, behind: 1 }, + }); + + const result = await dispatchAndRecover(session, { + kind: 'pull', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ kind: 'succeeded' }); + expect( + (await fixture.repository.git('rev-parse', 'HEAD')).stdout.trim(), + ).toBe(expectedHead); + expect( + ( + await fixture.repository.git( + '--git-dir', + fixture.remotePath, + 'show-ref', + '--verify', + 'refs/heads/source', + ) + ).stdout.trim(), + ).toContain(expectedHead); + await session.close(); + }); + + it('Push targets branch..merge instead of the Remote-tracking alias', async () => { + const fixture = await customMappingFixture(); + await writeFile( + join(fixture.repository.path, 'local.txt'), + 'mapped local change\n', + ); + await fixture.repository.git('add', '--', 'local.txt'); + await fixture.repository.git( + 'commit', + '--quiet', + '-m', + 'Advance mapped local', + ); + const expectedHead = ( + await fixture.repository.git('rev-parse', 'HEAD') + ).stdout.trim(); + const session = await createRepositoryEngine().open( + fixture.repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + + const result = await dispatchAndRecover(session, { + kind: 'push', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ kind: 'succeeded' }); + expect( + ( + await fixture.repository.git( + '--git-dir', + fixture.remotePath, + 'rev-parse', + 'refs/heads/source', + ) + ).stdout.trim(), + ).toBe(expectedHead); + await expect( + fixture.repository.git( + '--git-dir', + fixture.remotePath, + 'show-ref', + '--verify', + 'refs/heads/alias', + ), + ).rejects.toThrow(); + await session.close(); + }); + + it('Pull fast-forwards a clean behind Local Branch from its exact Upstream', async () => { + const fixture = await trackingFixture(); + const producer = await cloneRemote(fixture.remotePath); + await writeFile(join(producer.path, 'remote.txt'), 'remote change\n'); + await producer.git('add', '--', 'remote.txt'); + await producer.git('commit', '--quiet', '-m', 'Advance Remote'); + const expectedHead = ( + await producer.git('rev-parse', 'HEAD') + ).stdout.trim(); + await producer.git('push', '--quiet', 'origin', fixture.branch); + await fixture.repository.git('fetch', '--quiet', 'origin'); + const session = await createRepositoryEngine().open( + fixture.repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + expect(worktree.upstream).toMatchObject({ + kind: 'tracking', + aheadBehind: { kind: 'cached', ahead: 0, behind: 1 }, + }); + + const result = await dispatchAndRecover(session, { + kind: 'pull', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ + kind: 'succeeded', + result: { kind: 'remote', summary: `Pulled ${fixture.branch}.` }, + }); + expect( + (await fixture.repository.git('rev-parse', 'HEAD')).stdout.trim(), + ).toBe(expectedHead); + expect(await readStatus(fixture.repository)).toBe(''); + await session.close(); + }); + + it('Pull is a no-op when the Local Branch is ahead', async () => { + const fixture = await trackingFixture(); + await writeFile(join(fixture.repository.path, 'ahead.txt'), 'ahead\n'); + await fixture.repository.git('add', '--', 'ahead.txt'); + await fixture.repository.git('commit', '--quiet', '-m', 'Ahead Commit'); + const headBefore = ( + await fixture.repository.git('rev-parse', 'HEAD') + ).stdout.trim(); + const session = await createRepositoryEngine().open( + fixture.repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + + const result = await dispatchAndRecover(session, { + kind: 'pull', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ + kind: 'succeeded', + result: { kind: 'no_change' }, + }); + expect( + (await fixture.repository.git('rev-parse', 'HEAD')).stdout.trim(), + ).toBe(headBefore); + await session.close(); + }); + + it('Pull blocks divergence without changing files or refs', async () => { + const fixture = await trackingFixture(); + const producer = await cloneRemote(fixture.remotePath); + await writeFile(join(producer.path, 'remote.txt'), 'remote\n'); + await producer.git('add', '--', 'remote.txt'); + await producer.git('commit', '--quiet', '-m', 'Remote Commit'); + await producer.git('push', '--quiet', 'origin', fixture.branch); + await writeFile(join(fixture.repository.path, 'local.txt'), 'local\n'); + await fixture.repository.git('add', '--', 'local.txt'); + await fixture.repository.git('commit', '--quiet', '-m', 'Local Commit'); + await fixture.repository.git('fetch', '--quiet', 'origin'); + const headBefore = ( + await fixture.repository.git('rev-parse', 'HEAD') + ).stdout.trim(); + const refsBefore = (await fixture.repository.git('show-ref')).stdout; + const session = await createRepositoryEngine().open( + fixture.repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + + const result = await dispatchAndRecover(session, { + kind: 'pull', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ + kind: 'rejected', + code: 'precondition_failed', + message: expect.stringContaining('Merge or Rebase explicitly'), + }); + expect( + (await fixture.repository.git('rev-parse', 'HEAD')).stdout.trim(), + ).toBe(headBefore); + expect((await fixture.repository.git('show-ref')).stdout).toBe(refsBefore); + await session.close(); + }); + + it('Push transfers committed history and leaves uncommitted content local', async () => { + const fixture = await trackingFixture(); + await writeFile( + join(fixture.repository.path, 'committed.txt'), + 'committed\n', + ); + await fixture.repository.git('add', '--', 'committed.txt'); + await fixture.repository.git('commit', '--quiet', '-m', 'Local Commit'); + const expectedRemoteHead = ( + await fixture.repository.git('rev-parse', 'HEAD') + ).stdout.trim(); + await writeFile( + join(fixture.repository.path, 'uncommitted.txt'), + 'local only\n', + ); + const session = await createRepositoryEngine().open( + fixture.repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + + const result = await dispatchAndRecover(session, { + kind: 'push', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ + kind: 'succeeded', + result: { kind: 'remote', summary: `Pushed ${fixture.branch}.` }, + }); + expect( + ( + await fixture.repository.git( + '--git-dir', + fixture.remotePath, + 'rev-parse', + `refs/heads/${fixture.branch}`, + ) + ).stdout.trim(), + ).toBe(expectedRemoteHead); + expect(await readStatus(fixture.repository)).toContain('uncommitted.txt'); + await session.close(); + }); + + it('Push blocks a known behind Local Branch', async () => { + const fixture = await trackingFixture(); + const producer = await cloneRemote(fixture.remotePath); + await writeFile(join(producer.path, 'remote.txt'), 'remote\n'); + await producer.git('add', '--', 'remote.txt'); + await producer.git('commit', '--quiet', '-m', 'Remote Commit'); + await producer.git('push', '--quiet', 'origin', fixture.branch); + await fixture.repository.git('fetch', '--quiet', 'origin'); + const remoteHead = ( + await fixture.repository.git( + '--git-dir', + fixture.remotePath, + 'rev-parse', + `refs/heads/${fixture.branch}`, + ) + ).stdout.trim(); + const session = await createRepositoryEngine().open( + fixture.repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + + const result = await dispatchAndRecover(session, { + kind: 'push', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ + kind: 'rejected', + code: 'precondition_failed', + message: expect.stringContaining('behind or diverged'), + }); + expect( + ( + await fixture.repository.git( + '--git-dir', + fixture.remotePath, + 'rev-parse', + `refs/heads/${fixture.branch}`, + ) + ).stdout.trim(), + ).toBe(remoteHead); + await session.close(); + }); + + it('Push reports a protected-Branch policy rejection without retrying', async () => { + const fixture = await trackingFixture(); + await writeFile(join(fixture.repository.path, 'local.txt'), 'local\n'); + await fixture.repository.git('add', '--', 'local.txt'); + await fixture.repository.git('commit', '--quiet', '-m', 'Local Commit'); + const hook = join(fixture.remotePath, 'hooks', 'pre-receive'); + await writeFile( + hook, + '#!/bin/sh\necho "protected branch update rejected" >&2\nexit 1\n', + ); + await chmod(hook, 0o755); + const session = await createRepositoryEngine().open( + fixture.repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + + const result = await dispatchAndRecover(session, { + kind: 'push', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ + kind: 'failed_known', + code: 'policy', + message: 'The policy for the Branch in the Remote rejected the update.', + }); + expect(JSON.stringify(result)).not.toContain(fixture.remotePath); + await session.close(); + }); + + it('Push reports a non-fast-forward race and never retries with force', async () => { + const fixture = await trackingFixture(); + const producer = await cloneRemote(fixture.remotePath); + await writeFile(join(fixture.repository.path, 'local.txt'), 'local\n'); + await fixture.repository.git('add', '--', 'local.txt'); + await fixture.repository.git('commit', '--quiet', '-m', 'Local Commit'); + const session = await createRepositoryEngine().open( + fixture.repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + await writeFile(join(producer.path, 'remote.txt'), 'remote\n'); + await producer.git('add', '--', 'remote.txt'); + await producer.git('commit', '--quiet', '-m', 'Remote race'); + await producer.git('push', '--quiet', 'origin', fixture.branch); + + const result = await dispatchAndRecover(session, { + kind: 'push', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ + kind: 'failed_known', + code: 'non_fast_forward', + message: 'The Remote rejected a non-fast-forward update.', + }); + expect( + ( + await fixture.repository.git( + '--git-dir', + fixture.remotePath, + 'rev-parse', + `refs/heads/${fixture.branch}`, + ) + ).stdout.trim(), + ).toBe((await producer.git('rev-parse', 'HEAD')).stdout.trim()); + await session.close(); + }); + + it('Publish creates only the same-name Branch in the Remote and configures Upstream after success', async () => { + const repository = await createRepository(); + await writeFile(join(repository.path, 'README.md'), 'unpublished\n'); + await repository.git('add', '--', 'README.md'); + await repository.git('commit', '--quiet', '-m', 'Unpublished Commit'); + await repository.git('switch', '--quiet', '-c', 'feature/same-name'); + const localObjectId = ( + await repository.git('rev-parse', 'HEAD') + ).stdout.trim(); + const remotePath = await mkdtemp( + join(tmpdir(), 'codex-git-publish-remote-'), + ); + temporaryDirectories.push(remotePath); + await repository.git('init', '--quiet', '--bare', remotePath); + await repository.git('remote', 'add', 'origin', remotePath); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + const remote = before.remotes[0]!; + expect(worktree.upstream).toEqual({ kind: 'unpublished' }); + + const result = await dispatchAndRecover(session, { + kind: 'publish', + worktreeId: worktree.worktreeId, + remoteId: remote.remoteId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ + kind: 'succeeded', + result: { + kind: 'remote', + summary: 'Published feature/same-name to origin.', + }, + }); + expect( + ( + await repository.git( + '--git-dir', + remotePath, + 'rev-parse', + 'refs/heads/feature/same-name', + ) + ).stdout.trim(), + ).toBe(localObjectId); + expect( + ( + await repository.git( + 'for-each-ref', + '--format=%(upstream)', + 'refs/heads/feature/same-name', + ) + ).stdout.trim(), + ).toBe('refs/remotes/origin/feature/same-name'); + await session.close(); + }); + + it('Publish reports Partial Success when the Branch in the Remote succeeds but Upstream configuration fails', async () => { + const repository = await createRepository(); + await writeFile(join(repository.path, 'README.md'), 'partial\n'); + await repository.git('add', '--', 'README.md'); + await repository.git('commit', '--quiet', '-m', 'Partial fixture'); + await repository.git('switch', '--quiet', '-c', 'partial-publish'); + const remotePath = await mkdtemp( + join(tmpdir(), 'codex-git-partial-remote-'), + ); + temporaryDirectories.push(remotePath); + await repository.git('init', '--quiet', '--bare', remotePath); + await repository.git('remote', 'add', 'origin', remotePath); + const session = await createRepositoryEngine().open( + repository.path as AbsolutePath, + ); + const before = await snapshot(session); + const worktree = before.worktrees[0]!; + const remote = before.remotes[0]!; + await writeFile(join(repository.path, '.git', 'config.lock'), 'occupied\n'); + + const result = await dispatchAndRecover(session, { + kind: 'publish', + worktreeId: worktree.worktreeId, + remoteId: remote.remoteId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: before.refsRevision, + }); + + expect(result).toMatchObject({ + kind: 'partial_success', + message: 'The Branch was published, but its Upstream was not configured.', + effects: [ + { kind: 'succeeded', label: 'Published partial-publish' }, + { + kind: 'failed_known', + label: 'Configure Upstream', + code: 'process_failed', + }, + ], + }); + expect( + ( + await repository.git( + '--git-dir', + remotePath, + 'rev-parse', + 'refs/heads/partial-publish', + ) + ).stdout.trim(), + ).toMatch(/^[0-9a-f]{40}$/u); + expect( + ( + await repository.git( + 'for-each-ref', + '--format=%(upstream)', + 'refs/heads/partial-publish', + ) + ).stdout.trim(), + ).toBe(''); + await session.close(); + }); +}); + +async function trackingFixture() { + const repository = await createRepository(); + await writeFile(join(repository.path, 'README.md'), 'fixture\n'); + await repository.git('add', '--', 'README.md'); + await repository.git('commit', '--quiet', '-m', 'Create fixture'); + const branch = ( + await repository.git('branch', '--show-current') + ).stdout.trim(); + const remotePath = await mkdtemp(join(tmpdir(), 'codex-git-sync-remote-')); + temporaryDirectories.push(remotePath); + await repository.git('init', '--quiet', '--bare', remotePath); + await repository.git('remote', 'add', 'origin', remotePath); + await repository.git('push', '--quiet', '-u', 'origin', branch); + return { branch, remotePath, repository }; +} + +async function customMappingFixture() { + const repository = await createRepository(); + await writeFile(join(repository.path, 'README.md'), 'fixture\n'); + await repository.git('add', '--', 'README.md'); + await repository.git('commit', '--quiet', '-m', 'Create fixture'); + const branch = ( + await repository.git('branch', '--show-current') + ).stdout.trim(); + const remotePath = await mkdtemp(join(tmpdir(), 'codex-git-mapped-remote-')); + temporaryDirectories.push(remotePath); + await repository.git('init', '--quiet', '--bare', remotePath); + await repository.git('remote', 'add', 'origin', remotePath); + await repository.git('push', '--quiet', 'origin', 'HEAD:refs/heads/source'); + await repository.git( + 'config', + 'remote.origin.fetch', + '+refs/heads/source:refs/remotes/origin/alias', + ); + await repository.git('fetch', '--quiet', 'origin'); + await repository.git('config', `branch.${branch}.remote`, 'origin'); + await repository.git('config', `branch.${branch}.merge`, 'refs/heads/source'); + return { branch, remotePath, repository }; +} + +async function cloneRemote(remotePath: string) { + const producer = await createTemporaryGitRepository(); + repositories.push(producer); + await configureIdentity(producer); + await producer.git('remote', 'add', 'origin', remotePath); + await producer.git('fetch', '--quiet', 'origin'); + const branch = ( + await producer.git( + 'for-each-ref', + '--format=%(refname:short)', + 'refs/remotes/origin', + ) + ).stdout + .split('\n') + .find((name) => name.startsWith('origin/') && name !== 'origin/HEAD')! + .slice('origin/'.length); + await producer.git('switch', '--quiet', '-c', branch, `origin/${branch}`); + return producer; +} + +async function cloneRemoteBranch(remotePath: string, branch: string) { + const producer = await createTemporaryGitRepository(); + repositories.push(producer); + await configureIdentity(producer); + await producer.git('remote', 'add', 'origin', remotePath); + await producer.git('fetch', '--quiet', 'origin', branch); + await producer.git('switch', '--quiet', '-c', branch, 'FETCH_HEAD'); + return producer; +} + +async function createRepository() { + const repository = await createTemporaryGitRepository(); + repositories.push(repository); + await configureIdentity(repository); + return repository; +} + +async function configureIdentity(repository: TemporaryGitRepository) { + await repository.git('config', 'user.name', 'Codex Git Tests'); + await repository.git('config', 'user.email', 'codex-git@example.test'); +} + +async function snapshot( + session: RepositorySession, +): Promise { + const result = await session.requestRefresh(); + if (result.kind !== 'repository') throw new Error('Expected Repository.'); + return result.repository; +} + +let commandSequence = 0; +async function dispatchAndRecover( + session: RepositorySession, + command: ProductCommand, +) { + commandSequence += 1; + const clientCommandId = `command_${commandSequence + .toString(16) + .padStart(32, '0')}` as ClientCommandId; + const receipt = await session.dispatch({ clientCommandId, command }); + return session.recoverOperation(receipt.operationId); +} + +async function readStatus(repository: TemporaryGitRepository) { + return (await repository.git('status', '--porcelain')).stdout; +}