From b1f4932d11e555a63690c7db361164fa06690174 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Mon, 31 Aug 2026 05:41:14 +0800 Subject: [PATCH] Implement safe Pull Push and Publish --- .../src/repository-protocol-adapter.ts | 7 +- apps/launcher/src/standalone-runtime.ts | 37 +- apps/ui/src/App.tsx | 3 + .../RepositoryOverview.interactions.test.tsx | 86 ++ apps/ui/src/RepositoryOverview.tsx | 188 +++- apps/ui/src/overview-fixtures.ts | 19 + .../ui/src/protocol-repository-source.test.ts | 60 ++ apps/ui/src/protocol-repository-source.ts | 42 + apps/ui/src/repository-overview-model.ts | 9 + apps/ui/src/repository-store.test.ts | 34 + apps/ui/src/repository-store.ts | 72 ++ packages/repository-engine/src/index.ts | 1 + .../src/observation-publication.ts | 7 +- .../src/remote-operation.test.ts | 211 +++++ .../repository-engine/src/remote-operation.ts | 214 +++++ .../src/repository-engine.ts | 3 + .../src/repository-publication.ts | 11 +- .../src/repository-refresh.ts | 2 + .../src/repository-session.test.ts | 326 ++++++- .../src/repository-session.ts | 819 ++++++++++++++++++ .../repository-sync.integration.test.ts | 644 ++++++++++++++ 21 files changed, 2780 insertions(+), 15 deletions(-) create mode 100644 packages/repository-engine/src/remote-operation.test.ts create mode 100644 packages/repository-engine/src/remote-operation.ts create mode 100644 tests/integration/repository-sync.integration.test.ts 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 8c42fdf..127e384 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -17,6 +17,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 f804c6c..2573a63 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 1a5ff2e..079025f 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

@@ -526,6 +620,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 96b10f9..0d9e3c5 100644 --- a/apps/ui/src/overview-fixtures.ts +++ b/apps/ui/src/overview-fixtures.ts @@ -109,6 +109,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; @@ -141,6 +146,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, @@ -169,6 +177,9 @@ export const oneWorktree: RepositoryOverviewSnapshot = { }, status: { kind: 'clean' }, changes: [], + nativeTargets: [ + { targetId: worktreeNativeTargetId, actions: ['open_terminal'] }, + ], upstream: { kind: 'tracking', displayName: 'origin/main', @@ -278,6 +289,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 76399a5..1e803fb 100644 --- a/apps/ui/src/protocol-repository-source.ts +++ b/apps/ui/src/protocol-repository-source.ts @@ -266,9 +266,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 11ed3e4..5d23095 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; @@ -115,4 +117,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 13d9be5..a4d4c34 100644 --- a/apps/ui/src/repository-store.test.ts +++ b/apps/ui/src/repository-store.test.ts @@ -1,10 +1,44 @@ import { describe, expect, it, vi } from 'vitest'; +import { operationIdSchema } from '@codex-git/protocol'; import { createOverviewFixture } from './overview-fixtures.js'; 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 21291dc..bfa04e9 100644 --- a/apps/ui/src/repository-store.ts +++ b/apps/ui/src/repository-store.ts @@ -5,6 +5,7 @@ import type { NativeActionRequest, NativeActionResult, RefId, + RemoteId, WorktreeId, } from '@codex-git/protocol'; @@ -35,6 +36,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, @@ -52,6 +65,7 @@ export interface RepositoryStoreSnapshot { readonly selectionNotice: string | null; readonly focusRecoveryRevision: number; readonly branchPicker: BranchPickerState; + readonly remoteOperation: RemoteOperationState; } export interface RepositoryStore { @@ -74,6 +88,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( @@ -94,6 +111,7 @@ export function createRepositoryStore( let focusRecoveryRevision = 0; let branchPicker: BranchPickerState = { kind: 'closed' }; let branchRequestGeneration = 0; + let remoteOperation: RemoteOperationState = { kind: 'idle' }; let storeSnapshot = buildSnapshot(); let disposed = false; @@ -312,8 +330,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; @@ -365,6 +436,7 @@ export function createRepositoryStore( selectionNotice, focusRecoveryRevision, branchPicker, + remoteOperation, }; } 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 4045a14..dc80550 100644 --- a/packages/repository-engine/src/observation-publication.ts +++ b/packages/repository-engine/src/observation-publication.ts @@ -25,6 +25,7 @@ export interface PublishedObservationWorktree extends Omit< DiscoveredWorktree, 'canonicalPathBytes' > { + readonly nativeTargetId: NativeTargetId; readonly worktreeRevision: number; readonly freshness: WorktreeFreshness; readonly index: IndexSnapshot | null; @@ -106,7 +107,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, @@ -136,6 +137,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 ff1b1a4..d8a7e99 100644 --- a/packages/repository-engine/src/repository-engine.ts +++ b/packages/repository-engine/src/repository-engine.ts @@ -31,6 +31,7 @@ import { type RepositorySession, } from './repository-session.js'; import { createRemoteFetcher } from './remote-fetch.js'; +import { createRemoteOperationExecutor } from './remote-operation.js'; import { cloneRemoteIdentityState, createRemoteIdentityState, @@ -41,6 +42,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; @@ -235,6 +237,7 @@ export function createRepositoryEngine(): RepositoryEngine { diff: (worktree, fileId) => readChangedFileDiff(worktree, fileId, 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.test.ts b/packages/repository-engine/src/repository-session.test.ts index 5b7863e..e6e568a 100644 --- a/packages/repository-engine/src/repository-session.test.ts +++ b/packages/repository-engine/src/repository-session.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import type { NativeTargetId } from '@codex-git/protocol'; +import type { + AbsolutePath, + ClientCommandId, + NativeTargetId, +} from '@codex-git/protocol'; import type { ScopedRepositoryPublicationSession } from './repository-publication.js'; import { createRepositorySession } from './repository-session.js'; @@ -51,3 +55,323 @@ describe('Repository File native targets', () => { await session.close(); }); }); + +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: { + 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, + close: async () => undefined, + } as unknown as ScopedRepositoryPublicationSession; + const session = createRepositorySession(delegate); + + await expect( + session.resolveWorktreeNativeTarget(targetId), + ).resolves.toEqual({ + worktreePath: '/projects/selected-worktree', + }); + + await session.close(); + }); +}); + +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, { + 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.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository.'); + const worktree = opened.repository.worktrees[0]!; + + const receipt = await session.dispatch({ + clientCommandId: + 'command_00000000000000000000000000000003' as ClientCommandId, + command: { + kind: 'push', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: opened.repository.refsRevision, + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'succeeded', + result: { kind: 'remote', summary: 'Pushed main.' }, + }); + await session.close(); + }); + + 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, { + 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.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository.'); + const worktree = opened.repository.worktrees[0]!; + + const receipt = await session.dispatch({ + clientCommandId: + 'command_00000000000000000000000000000004' as ClientCommandId, + command: { + kind: 'push', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: opened.repository.refsRevision, + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'unknown_outcome', + code: 'reconciliation_incomplete', + recoveryAvailable: true, + }); + await session.close(); + }); + + 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, { + 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.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository.'); + const worktree = opened.repository.worktrees[0]!; + + const receipt = await session.dispatch({ + clientCommandId: + 'command_00000000000000000000000000000001' as ClientCommandId, + command: { + kind: 'push', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: opened.repository.refsRevision, + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + expect(result).toMatchObject({ + kind: 'unknown_outcome', + code: 'reconciliation_incomplete', + recoveryAvailable: true, + }); + await session.close(); + }); + + 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.requestRefresh(); + if (opened.kind !== 'repository') throw new Error('Expected Repository.'); + const worktree = opened.repository.worktrees[0]!; + + const receipt = await session.dispatch({ + clientCommandId: + 'command_00000000000000000000000000000002' as ClientCommandId, + command: { + kind: 'push', + worktreeId: worktree.worktreeId, + expectedWorktreeRevision: worktree.worktreeRevision, + expectedRefsRevision: opened.repository.refsRevision, + }, + }); + const result = await session.recoverOperation(receipt.operationId); + + 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: 'unknown_outcome', + code: 'reconciliation_incomplete', + }); + await session.close(); + }); +}); + +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 { + 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 pushedRepositoryFixture() { + const fixture = remoteRepositoryFixture(); + const localObjectId = fixture.repository.worktrees[0]!.head.objectId!; + return { + ...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 }, + }, + })), + }, + }; +} diff --git a/packages/repository-engine/src/repository-session.ts b/packages/repository-engine/src/repository-session.ts index 1b9f19b..279c372 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 { RepositoryInvalidation, RepositoryOpenResult, @@ -38,6 +40,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; @@ -65,6 +70,10 @@ export interface RepositorySessionOptions { ) => Promise; readonly now?: () => Date; readonly runGit?: GitProcessRunner; + readonly executeRemoteOperation?: ( + request: import('./remote-operation.js').RemoteOperationRequest, + signal: AbortSignal, + ) => Promise; readonly diff?: ( worktree: RepositorySnapshot['worktrees'][number], fileId: FileId, @@ -102,6 +111,10 @@ export interface FileNativeTarget { readonly worktreePath: string; } +export interface WorktreeNativeTarget { + readonly worktreePath: string; +} + export interface InternalRepositorySession extends RepositorySession, ScopedRepositoryPublicationSession {} @@ -140,6 +153,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 = {}, @@ -217,6 +269,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()), @@ -539,6 +1263,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') { @@ -618,6 +1358,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 !== 'switch_branch') { throw new Error( 'This Repository Session does not support that command.', @@ -897,6 +1655,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; +}