From 696f0b7a3e4e490707724b912ca6b2302dfa22a1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:51:06 -0700 Subject: [PATCH] Consolidate sequencer continue operations Replace three separate operations (continueMerge, continueRebase, continueCherryPick) with continueSequencer(operation) across local, SSH, RPC, and relay handlers. Extract sequencer step definitions to shared GitSequencerOperation type, reducing API surface. --- .../use-mobile-conflict-sequencer-runners.ts | 15 ++-- src/main/git/sequencer-actions.test.ts | 39 ++++----- src/main/git/sequencer-actions.ts | 48 ++++------- .../ipc/filesystem-git-status-staging.test.ts | 43 +++++----- src/main/ipc/filesystem-test-harness.ts | 10 +-- src/main/ipc/filesystem.ts | 54 +++---------- src/main/providers/git-provider-contract.ts | 5 +- .../providers/ssh-git-provider-merge.test.ts | 38 ++++----- src/main/providers/ssh-git-provider.ts | 23 +----- .../orca-runtime-git-sequencer.test.ts | 44 +++++------ src/main/runtime/orca-runtime-git.ts | 8 +- src/main/runtime/orca-runtime.ts | 8 +- src/main/runtime/rpc/methods/git-params.ts | 4 + src/main/runtime/rpc/methods/git.test.ts | 16 ++-- src/main/runtime/rpc/methods/git.ts | 18 ++--- .../runtime/runtime-git-api-contract.test.ts | 4 +- src/main/runtime/runtime-git-sync-commands.ts | 40 ++-------- ...me-rpc-mobile-method-allowlist-fixtures.ts | 33 +++----- .../runtime-rpc-mobile-method-allowlist.ts | 4 +- src/preload/api/git-operation-api.ts | 9 ++- src/preload/index.ts | 12 +-- src/relay/git-handler.test.ts | 29 +++++-- src/relay/git-handler.ts | 37 ++++----- .../panel/head-identity-chip.tsx | 78 ++++++++++-------- .../sync/use-conflict-advance.test.tsx | 28 +++---- .../sync/use-conflict-advance.ts | 18 +---- .../runtime-git-client-api-contract.test.ts | 4 +- .../runtime/runtime-git-client-merge.test.ts | 73 +++++++++-------- .../src/runtime/runtime-git-client.ts | 8 +- .../src/runtime/runtime-git-sync-client.ts | 79 +++++-------------- .../src/web/preload-api/web-git-api.ts | 16 ++-- src/shared/git-sequencer-step.ts | 43 ++++++++++ 32 files changed, 382 insertions(+), 506 deletions(-) create mode 100644 src/shared/git-sequencer-step.ts diff --git a/mobile/src/source-control/use-mobile-conflict-sequencer-runners.ts b/mobile/src/source-control/use-mobile-conflict-sequencer-runners.ts index 383a3f4df45..00fef1df520 100644 --- a/mobile/src/source-control/use-mobile-conflict-sequencer-runners.ts +++ b/mobile/src/source-control/use-mobile-conflict-sequencer-runners.ts @@ -6,12 +6,6 @@ const ABORT_METHODS: Record = { rebase: 'git.abortRebase' } -const CONTINUE_METHODS: Record = { - merge: 'git.continueMerge', - rebase: 'git.continueRebase', - 'cherry-pick': 'git.continueCherryPick' -} - type RunGitAction = ( actionId: string, method: string, @@ -37,8 +31,13 @@ export function useMobileConflictSequencerRunners(runGitAction: RunGitAction) { [runForOperation] ), continueConflictOperation: useCallback( - (operation: string) => runForOperation('continue', CONTINUE_METHODS, operation), - [runForOperation] + async (operation: string) => { + if (operation !== 'merge' && operation !== 'rebase' && operation !== 'cherry-pick') { + return + } + await runGitAction(`continue-${operation}`, 'git.continueSequencer', { operation }) + }, + [runGitAction] ) } } diff --git a/src/main/git/sequencer-actions.test.ts b/src/main/git/sequencer-actions.test.ts index a84575fb7fa..982783f7871 100644 --- a/src/main/git/sequencer-actions.test.ts +++ b/src/main/git/sequencer-actions.test.ts @@ -17,14 +17,13 @@ vi.mock('./runner', () => }) ) -import { continueCherryPick, continueMerge, continueRebase } from './sequencer-actions' +import type { GitSequencerOperation } from '../../shared/git-sequencer-step' +import { continueSequencer } from './sequencer-actions' -type SequencerAction = (worktreePath: string, options?: { wslDistro?: string }) => Promise - -const CASES: readonly [string, SequencerAction, string[]][] = [ - ['continueMerge', continueMerge, ['merge', '--continue']], - ['continueRebase', continueRebase, ['rebase', '--continue']], - ['continueCherryPick', continueCherryPick, ['cherry-pick', '--continue']] +const CASES: readonly [GitSequencerOperation, string[]][] = [ + ['merge', ['merge', '--continue']], + ['rebase', ['rebase', '--continue']], + ['cherry-pick', ['cherry-pick', '--continue']] ] // The marker probe runs before the sequencer step, so calls are matched by argv, not index. @@ -48,8 +47,8 @@ describe('git sequencer actions', () => { gitExecFileAsyncMock.mockImplementation(markerProbe('abc123')) }) - it.each(CASES)('%s runs the matching git command in the worktree', async (_name, run, args) => { - await run('/repo') + it.each(CASES)('continues a %s with the matching git command', async (operation, args) => { + await continueSequencer(operation, '/repo') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( args, @@ -58,14 +57,14 @@ describe('git sequencer actions', () => { }) // Regression guard: without GIT_EDITOR the `--continue` child waits forever on the commit editor. - it.each(CASES)('%s suppresses the commit-message editor', async (_name, run, args) => { - await run('/repo') + it.each(CASES)('suppresses the commit-message editor for a %s', async (operation, args) => { + await continueSequencer(operation, '/repo') expect(optionsFor(args)?.env?.GIT_EDITOR).toBe('true') }) it('forwards runtime options such as the WSL distro', async () => { - await continueRebase('/repo', { wslDistro: 'Ubuntu' }) + await continueSequencer('rebase', '/repo', { wslDistro: 'Ubuntu' }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['rebase', '--continue'], @@ -86,10 +85,12 @@ describe('git sequencer actions', () => { return Promise.reject(new Error('error: could not apply ec9b3362... feat: add thing')) }) - await expect(continueRebase('/repo')).resolves.toBeUndefined() + await expect(continueSequencer('rebase', '/repo')).resolves.toBeUndefined() }) - it('treats a marker that cleared as the operation completing', async () => { + // Git exits 0 once it finishes the sequence, so a cleared marker with a nonzero exit means + // a hook or post-commit step failed — calling that success would hide it from the user. + it('surfaces a nonzero exit that cleared the marker instead of reporting completion', async () => { let mergeHead: string | null = 'aaa111' gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args[0] === 'rev-parse') { @@ -98,10 +99,10 @@ describe('git sequencer actions', () => { : Promise.reject(new Error('fatal: needed a single revision')) } mergeHead = null - return Promise.reject(new Error('warning: post-commit cleanup failed')) + return Promise.reject(new Error('post-commit cleanup failed')) }) - await expect(continueMerge('/repo')).resolves.toBeUndefined() + await expect(continueSequencer('merge', '/repo')).rejects.toThrow('post-commit cleanup failed') }) it('still fails a step that refused to run, leaving the marker where it was', async () => { @@ -111,7 +112,7 @@ describe('git sequencer actions', () => { : Promise.reject(new Error('f.txt: needs merge')) ) - await expect(continueRebase('/repo')).rejects.toThrow('needs merge') + await expect(continueSequencer('rebase', '/repo')).rejects.toThrow('needs merge') }) // The whole point of probing the marker instead of HEAD: another actor committing in @@ -123,7 +124,7 @@ describe('git sequencer actions', () => { : Promise.reject(new Error('f.txt: needs merge')) ) - await expect(continueRebase('/repo')).rejects.toThrow('needs merge') + await expect(continueSequencer('rebase', '/repo')).rejects.toThrow('needs merge') expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( ['rev-parse', '--verify', 'HEAD'], expect.anything() @@ -138,6 +139,6 @@ describe('git sequencer actions', () => { : Promise.reject(new Error('cherry-pick failed')) ) - await expect(continueCherryPick('/repo')).rejects.toThrow('cherry-pick failed') + await expect(continueSequencer('cherry-pick', '/repo')).rejects.toThrow('cherry-pick failed') }) }) diff --git a/src/main/git/sequencer-actions.ts b/src/main/git/sequencer-actions.ts index dc9e68045e5..f2cfa3a2754 100644 --- a/src/main/git/sequencer-actions.ts +++ b/src/main/git/sequencer-actions.ts @@ -1,4 +1,9 @@ import { editorSuppressedGitEnv } from '../../shared/git-sequencer-editor-env' +import { + gitSequencerAdvanced, + gitSequencerContinueStep, + type GitSequencerOperation +} from '../../shared/git-sequencer-step' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { gitExecFileAsync } from './runner' @@ -20,14 +25,13 @@ async function readSequencerMarkerOid( } } -// Why: everything here predates the Git 2.25 baseline (`merge --continue` 2.12, -// REBASE_HEAD 2.17), so no capability probe or fallback is needed. -async function runSequencerAction( - args: readonly [string, string], - marker: string, +/** Advances an in-progress merge/rebase/cherry-pick by one step. */ +export async function continueSequencer( + operation: GitSequencerOperation, worktreePath: string, - options: GitRuntimeOptions + options: GitRuntimeOptions = {} ): Promise { + const { args, marker } = gitSequencerContinueStep(operation) const markerBefore = await readSequencerMarkerOid(marker, worktreePath, options) try { await runWithGitReadCacheInvalidation(() => @@ -38,34 +42,14 @@ async function runSequencerAction( }) ) } catch (error) { - // Why: `--continue` also exits nonzero when it DID commit the resolution and the - // sequencer then stopped on the next commit. The operation's own marker ref moving - // (or clearing) is the proof it advanced — unlike HEAD, no concurrent commit in the - // worktree can touch it, so a refused step can never masquerade as progress. const markerAfter = await readSequencerMarkerOid(marker, worktreePath, options) - if (!markerBefore || markerAfter === markerBefore) { + if (!gitSequencerAdvanced(markerBefore, markerAfter)) { throw error } + // The sequencer advanced, but git still said something; losing it entirely hides hook failures. + console.warn( + `[git/sequencer] \`git ${args.join(' ')}\` advanced ${marker} to ${markerAfter} but exited nonzero:`, + error + ) } } - -export async function continueMerge( - worktreePath: string, - options: GitRuntimeOptions = {} -): Promise { - await runSequencerAction(['merge', '--continue'], 'MERGE_HEAD', worktreePath, options) -} - -export async function continueRebase( - worktreePath: string, - options: GitRuntimeOptions = {} -): Promise { - await runSequencerAction(['rebase', '--continue'], 'REBASE_HEAD', worktreePath, options) -} - -export async function continueCherryPick( - worktreePath: string, - options: GitRuntimeOptions = {} -): Promise { - await runSequencerAction(['cherry-pick', '--continue'], 'CHERRY_PICK_HEAD', worktreePath, options) -} diff --git a/src/main/ipc/filesystem-git-status-staging.test.ts b/src/main/ipc/filesystem-git-status-staging.test.ts index e7600fef4d4..855b716a74e 100644 --- a/src/main/ipc/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem-git-status-staging.test.ts @@ -9,9 +9,7 @@ import { getStatusMock, abortMergeMock, abortRebaseMock, - continueMergeMock, - continueRebaseMock, - continueCherryPickMock, + continueSequencerMock, stageFileMock, bulkStageFilesMock, bulkUnstageFilesMock, @@ -427,27 +425,30 @@ describe('registerFilesystemHandlers', () => { expect(sshProvider.abortRebase).toHaveBeenCalledWith('/remote/repo') }) - it.each([ - ['git:continueMerge', 'continueMerge', continueMergeMock], - ['git:continueRebase', 'continueRebase', continueRebaseMock], - ['git:continueCherryPick', 'continueCherryPick', continueCherryPickMock] - ])('routes %s through local and SSH git providers', async (channel, method, localMock) => { - registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) - localMock.mockResolvedValue(undefined) - const sshProvider = { [method]: vi.fn().mockResolvedValue(undefined) } - getSshGitProviderMock.mockReturnValue(sshProvider) + it.each(['merge', 'rebase', 'cherry-pick'] as const)( + 'routes a %s continue through local and SSH git providers', + async (operation) => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + continueSequencerMock.mockResolvedValue(undefined) + const sshProvider = { continueSequencer: vi.fn().mockResolvedValue(undefined) } + getSshGitProviderMock.mockReturnValue(sshProvider) - registerFilesystemHandlers(store as never) + registerFilesystemHandlers(store as never) - await handlers.get(channel)!(null, { worktreePath: WORKTREE_FEATURE_PATH }) - await handlers.get(channel)!(null, { - worktreePath: '/remote/repo', - connectionId: 'ssh-1' - }) + await handlers.get('git:continueSequencer')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + operation + }) + await handlers.get('git:continueSequencer')!(null, { + worktreePath: '/remote/repo', + operation, + connectionId: 'ssh-1' + }) - expect(localMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {}) - expect(sshProvider[method]).toHaveBeenCalledWith('/remote/repo') - }) + expect(continueSequencerMock).toHaveBeenCalledWith(operation, WORKTREE_FEATURE_PATH, {}) + expect(sshProvider.continueSequencer).toHaveBeenCalledWith('/remote/repo', operation) + } + ) it('rejects git file paths that escape the selected worktree', async () => { registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index 5b12a6e7269..9d441162849 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -30,9 +30,7 @@ export const commitChangesMock: IpcMock = vi.fn() export const getStatusMock: IpcMock = vi.fn() export const abortMergeMock: IpcMock = vi.fn() export const abortRebaseMock: IpcMock = vi.fn() -export const continueMergeMock: IpcMock = vi.fn() -export const continueRebaseMock: IpcMock = vi.fn() -export const continueCherryPickMock: IpcMock = vi.fn() +export const continueSequencerMock: IpcMock = vi.fn() export const getDiffMock: IpcMock = vi.fn() export const getBranchCompareMock: IpcMock = vi.fn() export const getBranchDiffMock: IpcMock = vi.fn() @@ -105,11 +103,7 @@ export const gitStatusModuleMock = { discardChanges: discardChangesMock } -export const gitSequencerActionsModuleMock = { - continueMerge: continueMergeMock, - continueRebase: continueRebaseMock, - continueCherryPick: continueCherryPickMock -} +export const gitSequencerActionsModuleMock = { continueSequencer: continueSequencerMock } export const gitIgnoredPathsMock = { checkIgnoredPaths: checkIgnoredPathsMock } diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 2d661bd6688..7d9e4c62079 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -60,7 +60,8 @@ import { getCommitCompare, getCommitDiff } from '../git/status' -import { continueCherryPick, continueMerge, continueRebase } from '../git/sequencer-actions' +import { continueSequencer } from '../git/sequencer-actions' +import type { GitSequencerOperation } from '../../shared/git-sequencer-step' import { getHistory } from '../git/history' import { cancelGenerateCommitMessageLocal, @@ -1391,14 +1392,17 @@ export function registerFilesystemHandlers( ) ipcMain.handle( - 'git:continueMerge', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + 'git:continueSequencer', + async ( + _event, + args: { worktreePath: string; operation: GitSequencerOperation; connectionId?: string } + ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(`No git provider for connection "${args.connectionId}"`) } - return provider.continueMerge(args.worktreePath) + return provider.continueSequencer(args.worktreePath, args.operation) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -1406,47 +1410,7 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - await continueMerge(worktreePath, gitOptions) - } - ) - - ipcMain.handle( - 'git:continueRebase', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${args.connectionId}"`) - } - return provider.continueRebase(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await continueRebase(worktreePath, gitOptions) - } - ) - - ipcMain.handle( - 'git:continueCherryPick', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${args.connectionId}"`) - } - return provider.continueCherryPick(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await continueCherryPick(worktreePath, gitOptions) + await continueSequencer(args.operation, worktreePath, gitOptions) } ) diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts index 02fdd240109..2c78f4c4355 100644 --- a/src/main/providers/git-provider-contract.ts +++ b/src/main/providers/git-provider-contract.ts @@ -4,6 +4,7 @@ import type { GitDiffResult } from '../../shared/git-diff-compare-types' import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitSequencerOperation } from '../../shared/git-sequencer-step' import type { GitConflictOperation, GitStagingArea, @@ -45,9 +46,7 @@ export type IGitProvider = { detectConflictOperation(worktreePath: string): Promise abortMerge(worktreePath: string): Promise abortRebase(worktreePath: string): Promise - continueMerge(worktreePath: string): Promise - continueRebase(worktreePath: string): Promise - continueCherryPick(worktreePath: string): Promise + continueSequencer(worktreePath: string, operation: GitSequencerOperation): Promise checkoutBranch(worktreePath: string, branch: string): Promise listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }> getBranchCompare( diff --git a/src/main/providers/ssh-git-provider-merge.test.ts b/src/main/providers/ssh-git-provider-merge.test.ts index d25fc5111a6..85d5a77dcae 100644 --- a/src/main/providers/ssh-git-provider-merge.test.ts +++ b/src/main/providers/ssh-git-provider-merge.test.ts @@ -19,26 +19,26 @@ describe('SshGitProvider merge operations', () => { }) }) - it.each([ - ['continueMerge', 'git.continueMerge'], - ['continueRebase', 'git.continueRebase'], - ['continueCherryPick', 'git.continueCherryPick'] - ] as const)('%s sends the %s request', async (method, rpcMethod) => { - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn(), - onNotification: vi.fn(), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false) + it.each(['merge', 'rebase', 'cherry-pick'] as const)( + 'continueSequencer sends git.continueSequencer for a %s', + async (operation) => { + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } + const provider = new SshGitProvider('conn-1', mux as never) + + await provider.continueSequencer('/home/user/repo', operation) + + expect(mux.request).toHaveBeenCalledWith('git.continueSequencer', { + worktreePath: '/home/user/repo', + operation + }) } - const provider = new SshGitProvider('conn-1', mux as never) - - await provider[method]('/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith(rpcMethod, { - worktreePath: '/home/user/repo' - }) - }) + ) it('abortRebase sends git.abortRebase request', async () => { const mux = { diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 2e678fb39b0..f8ed4b6adf1 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -1,5 +1,6 @@ import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' +import type { GitSequencerOperation } from '../../shared/git-sequencer-step' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' import { describeMaxBufferOverflowError, @@ -61,31 +62,15 @@ export class SshGitProvider extends SshGitWorktreeProvider implements IGitProvid return result as { stdout: string; stderr: string } } - async continueMerge(worktreePath: string): Promise { - await this.sequencerRequest('git.continueMerge', worktreePath, 'continue a merge') - } - - async continueRebase(worktreePath: string): Promise { - await this.sequencerRequest('git.continueRebase', worktreePath, 'continue a rebase') - } - - async continueCherryPick(worktreePath: string): Promise { - await this.sequencerRequest('git.continueCherryPick', worktreePath, 'continue a cherry-pick') - } - - private async sequencerRequest( - method: string, - worktreePath: string, - action: string - ): Promise { + async continueSequencer(worktreePath: string, operation: GitSequencerOperation): Promise { try { await this.runWithGitReadInvalidation(async () => { - await this.mux.request(method, { worktreePath }) + await this.mux.request('git.continueSequencer', { worktreePath, operation }) }) } catch (error) { if (isJsonRpcMethodNotFoundError(error)) { throw new Error( - `This SSH host is running an older Orca relay that cannot ${action}. Reconnect to deploy the latest relay, then try again.` + `This SSH host is running an older Orca relay that cannot continue a ${operation}. Reconnect to deploy the latest relay, then try again.` ) } throw error diff --git a/src/main/runtime/orca-runtime-git-sequencer.test.ts b/src/main/runtime/orca-runtime-git-sequencer.test.ts index e1306bd80ee..73746a6b220 100644 --- a/src/main/runtime/orca-runtime-git-sequencer.test.ts +++ b/src/main/runtime/orca-runtime-git-sequencer.test.ts @@ -3,16 +3,12 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' const mocks = vi.hoisted(() => ({ - continueMerge: vi.fn(), - continueRebase: vi.fn(), - continueCherryPick: vi.fn(), + continueSequencer: vi.fn(), getSshGitProvider: vi.fn() })) vi.mock('../git/sequencer-actions', () => ({ - continueMerge: mocks.continueMerge, - continueRebase: mocks.continueRebase, - continueCherryPick: mocks.continueCherryPick + continueSequencer: mocks.continueSequencer })) vi.mock('../providers/ssh-git-dispatch', () => ({ @@ -20,11 +16,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable' })) -const CASES = [ - ['continueRuntimeGitMerge', 'continueMerge'], - ['continueRuntimeGitRebase', 'continueRebase'], - ['continueRuntimeGitCherryPick', 'continueCherryPick'] -] as const +const OPERATIONS = ['merge', 'rebase', 'cherry-pick'] as const function makeCommands(connectionId?: string): RuntimeGitCommands { const worktree = { id: 'wt-1', repoId: 'repo-1', path: '/repo' } as ResolvedRuntimeGitWorktree @@ -44,28 +36,34 @@ describe('RuntimeGitCommands sequencer continue', () => { } }) - it.each(CASES)('%s runs %s against the resolved worktree', async (command, gitFn) => { - mocks[gitFn].mockResolvedValue(undefined) + it.each(OPERATIONS)('continues a %s against the resolved worktree', async (operation) => { + mocks.continueSequencer.mockResolvedValue(undefined) - await expect(makeCommands()[command]('id:wt-1')).resolves.toEqual({ ok: true }) + await expect(makeCommands().continueRuntimeGitSequencer('id:wt-1', operation)).resolves.toEqual( + { ok: true } + ) - expect(mocks[gitFn]).toHaveBeenCalledWith('/repo', {}) + expect(mocks.continueSequencer).toHaveBeenCalledWith(operation, '/repo', {}) }) - it.each(CASES)('%s routes through the SSH git provider', async (command, gitFn) => { - const provider = { [gitFn]: vi.fn().mockResolvedValue(undefined) } + it.each(OPERATIONS)('routes a %s through the SSH git provider', async (operation) => { + const provider = { continueSequencer: vi.fn().mockResolvedValue(undefined) } mocks.getSshGitProvider.mockReturnValue(provider) - await expect(makeCommands('conn-1')[command]('id:wt-1')).resolves.toEqual({ ok: true }) + await expect( + makeCommands('conn-1').continueRuntimeGitSequencer('id:wt-1', operation) + ).resolves.toEqual({ ok: true }) - expect(provider[gitFn]).toHaveBeenCalledWith('/repo') - expect(mocks[gitFn]).not.toHaveBeenCalled() + expect(provider.continueSequencer).toHaveBeenCalledWith('/repo', operation) + expect(mocks.continueSequencer).not.toHaveBeenCalled() }) - it.each(CASES)('%s fails when the SSH git provider is missing', async (command, gitFn) => { + it('fails when the SSH git provider is missing', async () => { mocks.getSshGitProvider.mockReturnValue(null) - await expect(makeCommands('conn-1')[command]('id:wt-1')).rejects.toThrow() - expect(mocks[gitFn]).not.toHaveBeenCalled() + await expect( + makeCommands('conn-1').continueRuntimeGitSequencer('id:wt-1', 'rebase') + ).rejects.toThrow() + expect(mocks.continueSequencer).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 3d0b537af5f..3e85d0f71c0 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -27,9 +27,7 @@ export class RuntimeGitCommands { readonly getRuntimeGitRemoteCommitUrl: RuntimeGitDiffCommands['getRuntimeGitRemoteCommitUrl'] readonly abortRuntimeGitMerge: RuntimeGitSyncCommands['abortRuntimeGitMerge'] readonly abortRuntimeGitRebase: RuntimeGitSyncCommands['abortRuntimeGitRebase'] - readonly continueRuntimeGitMerge: RuntimeGitSyncCommands['continueRuntimeGitMerge'] - readonly continueRuntimeGitRebase: RuntimeGitSyncCommands['continueRuntimeGitRebase'] - readonly continueRuntimeGitCherryPick: RuntimeGitSyncCommands['continueRuntimeGitCherryPick'] + readonly continueRuntimeGitSequencer: RuntimeGitSyncCommands['continueRuntimeGitSequencer'] readonly getRuntimeGitUpstreamStatus: RuntimeGitSyncCommands['getRuntimeGitUpstreamStatus'] readonly fetchRuntimeGit: RuntimeGitSyncCommands['fetchRuntimeGit'] readonly syncRuntimeGitForkDefaultBranch: RuntimeGitSyncCommands['syncRuntimeGitForkDefaultBranch'] @@ -73,9 +71,7 @@ export class RuntimeGitCommands { this.getRuntimeGitRemoteCommitUrl = diff.getRuntimeGitRemoteCommitUrl.bind(diff) this.abortRuntimeGitMerge = sync.abortRuntimeGitMerge.bind(sync) this.abortRuntimeGitRebase = sync.abortRuntimeGitRebase.bind(sync) - this.continueRuntimeGitMerge = sync.continueRuntimeGitMerge.bind(sync) - this.continueRuntimeGitRebase = sync.continueRuntimeGitRebase.bind(sync) - this.continueRuntimeGitCherryPick = sync.continueRuntimeGitCherryPick.bind(sync) + this.continueRuntimeGitSequencer = sync.continueRuntimeGitSequencer.bind(sync) this.getRuntimeGitUpstreamStatus = sync.getRuntimeGitUpstreamStatus.bind(sync) this.fetchRuntimeGit = sync.fetchRuntimeGit.bind(sync) this.syncRuntimeGitForkDefaultBranch = sync.syncRuntimeGitForkDefaultBranch.bind(sync) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 32bfd812d0d..110420dfc87 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11610,12 +11610,8 @@ export class OrcaRuntimeService { this.gitCommands.abortRuntimeGitMerge.bind(this.gitCommands) abortRuntimeGitRebase: RuntimeGitCommands['abortRuntimeGitRebase'] = this.gitCommands.abortRuntimeGitRebase.bind(this.gitCommands) - continueRuntimeGitMerge: RuntimeGitCommands['continueRuntimeGitMerge'] = - this.gitCommands.continueRuntimeGitMerge.bind(this.gitCommands) - continueRuntimeGitRebase: RuntimeGitCommands['continueRuntimeGitRebase'] = - this.gitCommands.continueRuntimeGitRebase.bind(this.gitCommands) - continueRuntimeGitCherryPick: RuntimeGitCommands['continueRuntimeGitCherryPick'] = - this.gitCommands.continueRuntimeGitCherryPick.bind(this.gitCommands) + continueRuntimeGitSequencer: RuntimeGitCommands['continueRuntimeGitSequencer'] = + this.gitCommands.continueRuntimeGitSequencer.bind(this.gitCommands) checkoutRuntimeGitBranch: RuntimeGitCommands['checkoutRuntimeGitBranch'] = this.gitCommands.checkoutRuntimeGitBranch.bind(this.gitCommands) listRuntimeGitLocalBranches: RuntimeGitCommands['listRuntimeGitLocalBranches'] = diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index f69b01cd053..4504414e016 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -18,6 +18,10 @@ export const GitStatusParams = WorktreeSelector.extend({ branchLineTotalMergeBase: z.string().optional() }) +export const GitContinueSequencer = WorktreeSelector.extend({ + operation: z.enum(['merge', 'rebase', 'cherry-pick']) +}) + export const GitCheckIgnored = WorktreeSelector.extend({ paths: z.array(z.string().min(1, 'Missing path')).max(2000) }) diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index ecb6f392599..9c5607704cf 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -306,9 +306,7 @@ describe('git RPC methods', () => { cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }), abortRuntimeGitMerge: vi.fn().mockResolvedValue({ ok: true }), abortRuntimeGitRebase: vi.fn().mockResolvedValue({ ok: true }), - continueRuntimeGitMerge: vi.fn().mockResolvedValue({ ok: true }), - continueRuntimeGitRebase: vi.fn().mockResolvedValue({ ok: true }), - continueRuntimeGitCherryPick: vi.fn().mockResolvedValue({ ok: true }), + continueRuntimeGitSequencer: vi.fn().mockResolvedValue({ ok: true }), pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }), getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3'), getRuntimeGitRemoteCommitUrl: vi.fn().mockResolvedValue('https://example.com/commit/abc') @@ -332,8 +330,10 @@ describe('git RPC methods', () => { ) await dispatcher.dispatch(makeRequest('git.abortMerge', { worktree: 'id:wt-1' })) await dispatcher.dispatch(makeRequest('git.abortRebase', { worktree: 'id:wt-1' })) - for (const method of ['git.continueMerge', 'git.continueRebase', 'git.continueCherryPick']) { - await dispatcher.dispatch(makeRequest(method, { worktree: 'id:wt-1' })) + for (const operation of ['merge', 'rebase', 'cherry-pick']) { + await dispatcher.dispatch( + makeRequest('git.continueSequencer', { worktree: 'id:wt-1', operation }) + ) } await dispatcher.dispatch( makeRequest('git.push', { @@ -364,9 +364,9 @@ describe('git RPC methods', () => { expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1') expect(runtime.abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1') expect(runtime.abortRuntimeGitRebase).toHaveBeenCalledWith('id:wt-1') - expect(runtime.continueRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1') - expect(runtime.continueRuntimeGitRebase).toHaveBeenCalledWith('id:wt-1') - expect(runtime.continueRuntimeGitCherryPick).toHaveBeenCalledWith('id:wt-1') + expect(runtime.continueRuntimeGitSequencer).toHaveBeenCalledWith('id:wt-1', 'merge') + expect(runtime.continueRuntimeGitSequencer).toHaveBeenCalledWith('id:wt-1', 'rebase') + expect(runtime.continueRuntimeGitSequencer).toHaveBeenCalledWith('id:wt-1', 'cherry-pick') expect(runtime.pushRuntimeGit).toHaveBeenCalledWith( 'id:wt-1', true, diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 4822852fd13..3c18811d0d2 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -8,6 +8,7 @@ import { GitCheckout, GitCommit, GitCommitCompare, + GitContinueSequencer, GitFilePath, GitForkSync, GitHistory, @@ -94,19 +95,10 @@ export const GIT_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.abortRuntimeGitRebase(params.worktree) }), defineMethod({ - name: 'git.continueMerge', - params: WorktreeSelector, - handler: async (params, { runtime }) => runtime.continueRuntimeGitMerge(params.worktree) - }), - defineMethod({ - name: 'git.continueRebase', - params: WorktreeSelector, - handler: async (params, { runtime }) => runtime.continueRuntimeGitRebase(params.worktree) - }), - defineMethod({ - name: 'git.continueCherryPick', - params: WorktreeSelector, - handler: async (params, { runtime }) => runtime.continueRuntimeGitCherryPick(params.worktree) + name: 'git.continueSequencer', + params: GitContinueSequencer, + handler: async (params, { runtime }) => + runtime.continueRuntimeGitSequencer(params.worktree, params.operation) }), defineMethod({ name: 'git.checkout', diff --git a/src/main/runtime/runtime-git-api-contract.test.ts b/src/main/runtime/runtime-git-api-contract.test.ts index 7f4b8c21373..da252c6c337 100644 --- a/src/main/runtime/runtime-git-api-contract.test.ts +++ b/src/main/runtime/runtime-git-api-contract.test.ts @@ -11,9 +11,7 @@ const RPC_TO_RUNTIME_COMMAND = { 'git.conflictOperation': 'getRuntimeGitConflictOperation', 'git.abortMerge': 'abortRuntimeGitMerge', 'git.abortRebase': 'abortRuntimeGitRebase', - 'git.continueMerge': 'continueRuntimeGitMerge', - 'git.continueRebase': 'continueRuntimeGitRebase', - 'git.continueCherryPick': 'continueRuntimeGitCherryPick', + 'git.continueSequencer': 'continueRuntimeGitSequencer', 'git.checkout': 'checkoutRuntimeGitBranch', 'git.localBranches': 'listRuntimeGitLocalBranches', 'git.diff': 'getRuntimeGitDiff', diff --git a/src/main/runtime/runtime-git-sync-commands.ts b/src/main/runtime/runtime-git-sync-commands.ts index 3529f864b0d..811f3096707 100644 --- a/src/main/runtime/runtime-git-sync-commands.ts +++ b/src/main/runtime/runtime-git-sync-commands.ts @@ -1,10 +1,11 @@ import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitSequencerOperation } from '../../shared/git-sequencer-step' import type { GitUpstreamStatus } from '../../shared/git-status-types' import type { GitPushTarget } from '../../shared/worktree/types' import { gitSyncForkDefaultBranch } from '../git/fork-sync' import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' import { abortMerge, abortRebase, commitChanges } from '../git/status' -import { continueCherryPick, continueMerge, continueRebase } from '../git/sequencer-actions' +import { continueSequencer } from '../git/sequencer-actions' import { getUpstreamStatus } from '../git/upstream' import { getSshGitProvider, @@ -49,45 +50,20 @@ export class RuntimeGitSyncCommands { return { ok: true } } - async continueRuntimeGitMerge(worktreeSelector: string): Promise<{ ok: true }> { + async continueRuntimeGitSequencer( + worktreeSelector: string, + operation: GitSequencerOperation + ): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.continueMerge(target.worktree.path) + await provider.continueSequencer(target.worktree.path, operation) return { ok: true } } - await continueMerge(target.worktree.path, localGitOptionsForTarget(target)) - return { ok: true } - } - - async continueRuntimeGitRebase(worktreeSelector: string): Promise<{ ok: true }> { - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) - const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.continueRebase(target.worktree.path) - return { ok: true } - } - await continueRebase(target.worktree.path, localGitOptionsForTarget(target)) - return { ok: true } - } - - async continueRuntimeGitCherryPick(worktreeSelector: string): Promise<{ ok: true }> { - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) - const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.continueCherryPick(target.worktree.path) - return { ok: true } - } - await continueCherryPick(target.worktree.path, localGitOptionsForTarget(target)) + await continueSequencer(operation, target.worktree.path, localGitOptionsForTarget(target)) return { ok: true } } diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts index 174d94bf94a..20344a7be24 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts @@ -33,9 +33,7 @@ export function createMobileRpcSurfaceRuntime() { const rebaseRuntimeGitFromBase: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) const abortRuntimeGitMerge: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) const abortRuntimeGitRebase: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) - const continueRuntimeGitMerge: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) - const continueRuntimeGitRebase: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) - const continueRuntimeGitCherryPick: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) + const continueRuntimeGitSequencer: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) const bulkStageRuntimeGitPaths: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) const bulkUnstageRuntimeGitPaths: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) const getRuntimeGitDiff: MobileRpcMock = vi.fn().mockResolvedValue({ @@ -133,9 +131,7 @@ export function createMobileRpcSurfaceRuntime() { rebaseRuntimeGitFromBase, abortRuntimeGitMerge, abortRuntimeGitRebase, - continueRuntimeGitMerge, - continueRuntimeGitRebase, - continueRuntimeGitCherryPick, + continueRuntimeGitSequencer, bulkStageRuntimeGitPaths, bulkUnstageRuntimeGitPaths, getRuntimeGitDiff, @@ -187,29 +183,24 @@ export function createMobileRpcSurfaceRuntime() { } } -// Sequencer RPCs share one shape (worktree in, { ok: true } out), so the -// allowlist suite drives and asserts them as a set instead of line by line. -export const MOBILE_SEQUENCER_RPC_CASES = [ - ['git.continueMerge', 'continueRuntimeGitMerge'], - ['git.continueRebase', 'continueRuntimeGitRebase'], - ['git.continueCherryPick', 'continueRuntimeGitCherryPick'] -] as const - export async function dispatchMobileSequencerRpcs( dispatch: (request: Record) => Promise, deviceToken: string ): Promise { - for (const [method] of MOBILE_SEQUENCER_RPC_CASES) { - await dispatch({ id: `req_${method}`, method, deviceToken, params: { worktree: 'id:wt-1' } }) - } + await dispatch({ + id: 'req_git.continueSequencer', + method: 'git.continueSequencer', + deviceToken, + params: { worktree: 'id:wt-1', operation: 'rebase' } + }) } export function expectMobileSequencerRpcsDispatched( replies: Record[], mocks: ReturnType['mocks'] ): void { - for (const [method, runtimeMethod] of MOBILE_SEQUENCER_RPC_CASES) { - expect(replies).toContainEqual(expect.objectContaining({ id: `req_${method}`, ok: true })) - expect(mocks[runtimeMethod]).toHaveBeenCalledWith('id:wt-1') - } + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_git.continueSequencer', ok: true }) + ) + expect(mocks.continueRuntimeGitSequencer).toHaveBeenCalledWith('id:wt-1', 'rebase') } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index e48cdacd45d..323ede7feb7 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -60,9 +60,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'git.commit', 'git.commitCompare', 'git.commitDiff', - 'git.continueCherryPick', - 'git.continueMerge', - 'git.continueRebase', + 'git.continueSequencer', 'git.discard', 'git.discoverCommitMessageModels', 'git.diff', diff --git a/src/preload/api/git-operation-api.ts b/src/preload/api/git-operation-api.ts index 540d998b4f4..f9857b7c88c 100644 --- a/src/preload/api/git-operation-api.ts +++ b/src/preload/api/git-operation-api.ts @@ -1,4 +1,5 @@ import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitSequencerOperation } from '../../shared/git-sequencer-step' import type { TuiAgent } from '../../shared/tui-agent' import type { GitPushTarget } from '../../shared/worktree/types' import type { HostedReviewProvider } from '../../shared/hosted-review' @@ -9,9 +10,11 @@ export type GitOperationApi = { appendGitignore: (args: { worktreePath: string; folderName: string }) => Promise abortMerge: (args: { worktreePath: string; connectionId?: string }) => Promise abortRebase: (args: { worktreePath: string; connectionId?: string }) => Promise - continueMerge: (args: { worktreePath: string; connectionId?: string }) => Promise - continueRebase: (args: { worktreePath: string; connectionId?: string }) => Promise - continueCherryPick: (args: { worktreePath: string; connectionId?: string }) => Promise + continueSequencer: (args: { + worktreePath: string + operation: GitSequencerOperation + connectionId?: string + }) => Promise fetch: (args: { worktreePath: string connectionId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 83382ec7bfd..0f2b4cdced3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -20,6 +20,7 @@ import { } from '../shared/doc-preview-scheme' import type { DocPreviewGrantRequest } from './api/doc-preview-api' import type { AppIdentity } from '../shared/app-identity' +import type { GitSequencerOperation } from '../shared/git-sequencer-step' import type { MacCapturedDigitRowChord } from '../shared/macos-symbolic-hotkeys' import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { @@ -3641,12 +3642,11 @@ const api = { ipcRenderer.invoke('git:abortMerge', args), abortRebase: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:abortRebase', args), - continueMerge: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:continueMerge', args), - continueRebase: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:continueRebase', args), - continueCherryPick: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:continueCherryPick', args), + continueSequencer: (args: { + worktreePath: string + operation: GitSequencerOperation + connectionId?: string + }): Promise => ipcRenderer.invoke('git:continueSequencer', args), diff: (args: { worktreePath: string filePath: string diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index d989205df26..debd05eb8da 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -50,9 +50,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.bulkUnstage') expect(methods).toContain('git.abortMerge') expect(methods).toContain('git.abortRebase') - expect(methods).toContain('git.continueMerge') - expect(methods).toContain('git.continueRebase') - expect(methods).toContain('git.continueCherryPick') + expect(methods).toContain('git.continueSequencer') expect(methods).toContain('git.checkout') expect(methods).toContain('git.localBranches') expect(methods).toContain('git.discard') @@ -209,7 +207,10 @@ describe('GitHandler', () => { ).toThrow() resolveConflict() - await dispatcher.callRequest('git.continueRebase', { worktreePath: tmpDir }) + await dispatcher.callRequest('git.continueSequencer', { + worktreePath: tmpDir, + operation: 'rebase' + }) await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).rejects.toThrow() await expect(fs.access(path.join(tmpDir, '.git', 'rebase-apply'))).rejects.toThrow() @@ -228,7 +229,10 @@ describe('GitHandler', () => { // Git exits nonzero here (it committed step 1, then stopped on step 2's // conflict) — the moved HEAD must read as success, exactly like the local path. - await dispatcher.callRequest('git.continueRebase', { worktreePath: tmpDir }) + await dispatcher.callRequest('git.continueSequencer', { + worktreePath: tmpDir, + operation: 'rebase' + }) await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).resolves.toBeUndefined() }) @@ -242,7 +246,10 @@ describe('GitHandler', () => { // Conflict left unresolved: git refuses, HEAD does not move, the error surfaces. await expect( - dispatcher.callRequest('git.continueRebase', { worktreePath: tmpDir }) + dispatcher.callRequest('git.continueSequencer', { + worktreePath: tmpDir, + operation: 'rebase' + }) ).rejects.toThrow() await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).resolves.toBeUndefined() @@ -255,7 +262,10 @@ describe('GitHandler', () => { ).toThrow() resolveConflict() - await dispatcher.callRequest('git.continueMerge', { worktreePath: tmpDir }) + await dispatcher.callRequest('git.continueSequencer', { + worktreePath: tmpDir, + operation: 'merge' + }) await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).rejects.toThrow() await expect(readFileText()).resolves.toBe('resolved\n') @@ -268,7 +278,10 @@ describe('GitHandler', () => { ).toThrow() resolveConflict() - await dispatcher.callRequest('git.continueCherryPick', { worktreePath: tmpDir }) + await dispatcher.callRequest('git.continueSequencer', { + worktreePath: tmpDir, + operation: 'cherry-pick' + }) await expect(fs.access(path.join(tmpDir, '.git', 'CHERRY_PICK_HEAD'))).rejects.toThrow() await expect(readFileText()).resolves.toBe('resolved\n') diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 67e2773a747..bbcff0c1406 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,3 +1,8 @@ +import { + gitSequencerAdvanced, + gitSequencerContinueStep, + isGitSequencerOperation +} from '../shared/git-sequencer-step' import { execFile, spawn, type ExecFileOptions } from 'node:child_process' import { promisify } from 'node:util' import type { RelayDispatcher, RequestContext } from './dispatcher' @@ -103,15 +108,7 @@ export class GitHandler { (params, context) => this.responseAck(params, context), (params, context) => this.cancelResponseStream(params, context) ) - this.dispatcher.onRequest('git.continueMerge', (p) => - this.sequencerAction(p, ['merge', '--continue'], 'MERGE_HEAD') - ) - this.dispatcher.onRequest('git.continueRebase', (p) => - this.sequencerAction(p, ['rebase', '--continue'], 'REBASE_HEAD') - ) - this.dispatcher.onRequest('git.continueCherryPick', (p) => - this.sequencerAction(p, ['cherry-pick', '--continue'], 'CHERRY_PICK_HEAD') - ) + this.dispatcher.onRequest('git.continueSequencer', (p) => this.continueSequencer(p)) // Why: a detached client's git.responseAck frames never arrive; wake any pump parked on the ack window so it re-checks staleness and exits. this.dispatcher.onClientDetached?.(() => this.responseStreams.wakeAll()) } @@ -227,23 +224,27 @@ export class GitHandler { } } - // Why: sequencer continuations must not open an interactive commit-message editor. - private async sequencerAction( - params: Record, - args: string[], - marker: string - ) { + private async continueSequencer(params: Record) { + const operation = params.operation + if (!isGitSequencerOperation(operation)) { + throw new Error(`Unsupported sequencer operation: ${String(operation)}`) + } this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string + const { args, marker } = gitSequencerContinueStep(operation) const markerBefore = await this.readSequencerMarkerOid(worktreePath, marker) try { - await this.git(args, worktreePath, { suppressEditor: true, terminationBarrier: true }) + await this.git([...args], worktreePath, { suppressEditor: true, terminationBarrier: true }) } catch (error) { - // Why: --continue may advance to another conflicted step and still exit nonzero. const markerAfter = await this.readSequencerMarkerOid(worktreePath, marker) - if (!markerBefore || markerAfter === markerBefore) { + if (!gitSequencerAdvanced(markerBefore, markerAfter)) { throw error } + // The sequencer advanced, but git still reported an error; retain that signal for diagnostics. + console.warn( + `[relay/git] \`git ${args.join(' ')}\` advanced ${marker} to ${markerAfter} but exited nonzero:`, + error + ) } finally { this.clearGitMutationReadCaches() } diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/head-identity-chip.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/head-identity-chip.tsx index a8b8055b111..1e540b102de 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/head-identity-chip.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/head-identity-chip.tsx @@ -42,6 +42,42 @@ export function resolveHeadFlowLabel( return null } +/** + * Truncated branch chip with the full text in a tooltip. `block` is load-bearing: + * `truncate` clips nothing on an inline box, so an inline span would let long names + * run under the line-total chip. Native title omitted — Radix already shows the text. + */ +function TruncatedIdentityChip({ + ariaLabel, + tooltip, + operation, + children +}: { + ariaLabel: string + tooltip: string + operation?: OperationIdentity['operation'] + children: React.ReactNode +}): React.JSX.Element { + return ( + + + + {children} + + + + {tooltip} + + + ) +} + function OperationIdentityChip({ display }: { display: OperationIdentity }): React.JSX.Element { const qualifier = operationIdentityQualifier(display.operation) const ariaLabel = translate( @@ -60,23 +96,10 @@ function OperationIdentityChip({ display }: { display: OperationIdentity }): Rea : display.branchName return ( - - - - {display.branchName} - {` · ${qualifier}`} - - - - {tooltip} - - + + {display.branchName} + {` · ${qualifier}`} + ) } @@ -108,24 +131,9 @@ export function HeadIdentity({ ) // Why: focusable + tooltip so truncated long branch names stay discoverable. - // Native title omitted — Radix Tooltip already surfaces the full name on hover. - // `block` is load-bearing: `truncate` clips nothing on an inline box, so an - // inline span here let long names run under the line-total chip. return ( - - - - {display.branchName} - - - - {display.branchName} - - + + {display.branchName} + ) } diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.test.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.test.tsx index 7614f7828d2..64cf6678a34 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.test.tsx @@ -4,21 +4,15 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSourceControlConflictAdvance } from './use-conflict-advance' -const { runners, toastErrorMock } = vi.hoisted(() => ({ +const { continueSequencerMock, toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn(), - runners: { - continueMerge: vi.fn(), - continueRebase: vi.fn(), - continueCherryPick: vi.fn() - } + continueSequencerMock: vi.fn() })) vi.mock('sonner', () => ({ toast: { error: toastErrorMock } })) vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) vi.mock('@/runtime/runtime-git-client', () => ({ - continueRuntimeGitMerge: (...a: unknown[]) => runners.continueMerge(...a), - continueRuntimeGitRebase: (...a: unknown[]) => runners.continueRebase(...a), - continueRuntimeGitCherryPick: (...a: unknown[]) => runners.continueCherryPick(...a) + continueRuntimeGitSequencer: (...a: unknown[]) => continueSequencerMock(...a) })) vi.mock('./remote-refresh', () => ({ refreshSourceControlAfterRemoteAction: vi.fn() })) @@ -58,10 +52,10 @@ describe('useSourceControlConflictAdvance', () => { result.current.handleContinueOperation('rebase') }) - expect(runners.continueRebase).toHaveBeenCalledTimes(1) + expect(continueSequencerMock).toHaveBeenCalledWith(expect.anything(), 'rebase') }) - it('routes each operation to its own runner', async () => { + it('passes each operation through to the sequencer runner', async () => { const merge = setup({ conflictOperation: 'merge' }) await act(async () => { merge.result.current.handleContinueOperation('merge') @@ -71,9 +65,9 @@ describe('useSourceControlConflictAdvance', () => { cherry.result.current.handleContinueOperation('cherry-pick') }) - expect(runners.continueMerge).toHaveBeenCalledTimes(1) - expect(runners.continueCherryPick).toHaveBeenCalledTimes(1) - expect(runners.continueRebase).not.toHaveBeenCalled() + expect(continueSequencerMock).toHaveBeenCalledWith(expect.anything(), 'merge') + expect(continueSequencerMock).toHaveBeenCalledWith(expect.anything(), 'cherry-pick') + expect(continueSequencerMock).not.toHaveBeenCalledWith(expect.anything(), 'rebase') }) it('ignores a request for an operation that is no longer the one running', async () => { @@ -83,7 +77,7 @@ describe('useSourceControlConflictAdvance', () => { result.current.handleContinueOperation('rebase') }) - expect(runners.continueRebase).not.toHaveBeenCalled() + expect(continueSequencerMock).not.toHaveBeenCalled() }) it('refuses to advance while an abort is already in flight', async () => { @@ -93,11 +87,11 @@ describe('useSourceControlConflictAdvance', () => { result.current.handleContinueOperation('rebase') }) - expect(runners.continueRebase).not.toHaveBeenCalled() + expect(continueSequencerMock).not.toHaveBeenCalled() }) it('surfaces a failure and clears the in-flight flag', async () => { - runners.continueRebase.mockRejectedValueOnce(new Error('needs merge')) + continueSequencerMock.mockRejectedValueOnce(new Error('needs merge')) const { result } = setup() await act(async () => { diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.ts index 43343d461f4..3525847c81f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.ts @@ -1,22 +1,13 @@ import { useCallback } from 'react' import { translate } from '@/i18n/i18n' -import { - continueRuntimeGitCherryPick, - continueRuntimeGitMerge, - continueRuntimeGitRebase -} from '@/runtime/runtime-git-client' +import { continueRuntimeGitSequencer } from '@/runtime/runtime-git-client' +import { isGitSequencerOperation } from '../../../../../../shared/git-sequencer-step' import type { GitConflictOperation } from '../../../../../../shared/git-status-types' import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' import { useSourceControlConflictOperationRunner } from './use-conflict-operation-runner' import type { SourceControlStatusRefresh } from './use-status-refresh' -const CONTINUE_RUNNERS = { - merge: continueRuntimeGitMerge, - rebase: continueRuntimeGitRebase, - 'cherry-pick': continueRuntimeGitCherryPick -} as const - /** Continue for an in-progress merge/rebase/cherry-pick: moves the sequencer forward. */ export function useSourceControlConflictAdvance({ activeRepoSettings, @@ -58,8 +49,7 @@ export function useSourceControlConflictAdvance({ const handleContinueOperation = useCallback( (operation: GitConflictOperation): void => { - const runner = CONTINUE_RUNNERS[operation as keyof typeof CONTINUE_RUNNERS] - if (!runner) { + if (!isGitSequencerOperation(operation)) { return } void runConflictOperation({ @@ -70,7 +60,7 @@ export function useSourceControlConflictAdvance({ 'Continue {{value0}} failed', { value0: operation } ), - run: (context) => runner(context) + run: (context) => continueRuntimeGitSequencer(context, operation) }) }, [runConflictOperation] diff --git a/src/renderer/src/runtime/runtime-git-client-api-contract.test.ts b/src/renderer/src/runtime/runtime-git-client-api-contract.test.ts index 33954b25106..b715a0861de 100644 --- a/src/renderer/src/runtime/runtime-git-client-api-contract.test.ts +++ b/src/renderer/src/runtime/runtime-git-client-api-contract.test.ts @@ -10,9 +10,7 @@ const PUBLIC_RUNTIME_GIT_CLIENT_FUNCTIONS = [ 'cancelRuntimeGenerateCommitMessage', 'cancelRuntimeGeneratePullRequestFields', 'commitRuntimeGit', - 'continueRuntimeGitCherryPick', - 'continueRuntimeGitMerge', - 'continueRuntimeGitRebase', + 'continueRuntimeGitSequencer', 'discardRuntimeGitPath', 'discoverRuntimeCommitMessageModels', 'fastForwardRuntimeGit', diff --git a/src/renderer/src/runtime/runtime-git-client-merge.test.ts b/src/renderer/src/runtime/runtime-git-client-merge.test.ts index 6f18ebf1505..f5166ca5972 100644 --- a/src/renderer/src/runtime/runtime-git-client-merge.test.ts +++ b/src/renderer/src/runtime/runtime-git-client-merge.test.ts @@ -6,19 +6,13 @@ import { import { abortRuntimeGitMerge, abortRuntimeGitRebase, - continueRuntimeGitCherryPick, - continueRuntimeGitMerge, - continueRuntimeGitRebase + continueRuntimeGitSequencer } from './runtime-git-client' import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' const gitAbortMerge = vi.fn() const gitAbortRebase = vi.fn() -const gitSequencer = { - continueMerge: vi.fn(), - continueRebase: vi.fn(), - continueCherryPick: vi.fn() -} +const gitContinueSequencer = vi.fn() const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() const runtimeCall = vi.fn() @@ -27,10 +21,8 @@ beforeEach(() => { clearRuntimeCompatibilityCacheForTests() gitAbortMerge.mockReset() gitAbortRebase.mockReset() - for (const mock of Object.values(gitSequencer)) { - mock.mockReset() - mock.mockResolvedValue(undefined) - } + gitContinueSequencer.mockReset() + gitContinueSequencer.mockResolvedValue(undefined) runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() runtimeCall.mockReset() @@ -39,7 +31,11 @@ beforeEach(() => { }) vi.stubGlobal('window', { api: { - git: { abortMerge: gitAbortMerge, abortRebase: gitAbortRebase, ...gitSequencer }, + git: { + abortMerge: gitAbortMerge, + abortRebase: gitAbortRebase, + continueSequencer: gitContinueSequencer + }, runtime: { call: runtimeCall }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } @@ -97,30 +93,30 @@ describe('runtime git client merge operations', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) - const SEQUENCER_CASES = [ - [continueRuntimeGitMerge, 'continueMerge', 'git.continueMerge'], - [continueRuntimeGitRebase, 'continueRebase', 'git.continueRebase'], - [continueRuntimeGitCherryPick, 'continueCherryPick', 'git.continueCherryPick'] - ] as const + const SEQUENCER_OPERATIONS = ['merge', 'rebase', 'cherry-pick'] as const - it.each(SEQUENCER_CASES)('uses local git IPC for %#: %s', async (run, apiMethod) => { - await run({ - settings: { activeRuntimeEnvironmentId: null }, - worktreeId: 'wt-1', - worktreePath: '/repo', - connectionId: 'ssh-1' - }) + it.each(SEQUENCER_OPERATIONS)('uses local git IPC to continue a %s', async (operation) => { + await continueRuntimeGitSequencer( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }, + operation + ) - expect(gitSequencer[apiMethod]).toHaveBeenCalledWith({ + expect(gitContinueSequencer).toHaveBeenCalledWith({ connectionId: 'ssh-1', + operation, worktreePath: '/repo' }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) - it.each(SEQUENCER_CASES)( - 'routes %# through the active runtime as %s', - async (run, apiMethod, rpcMethod) => { + it.each(SEQUENCER_OPERATIONS)( + 'routes a %s continue through the active runtime', + async (operation) => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', ok: true, @@ -128,19 +124,22 @@ describe('runtime git client merge operations', () => { _meta: { runtimeId: 'remote-runtime' } }) - await run({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, - worktreeId: 'wt-1', - worktreePath: '/repo' - }) + await continueRuntimeGitSequencer( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + operation + ) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', - method: rpcMethod, - params: { worktree: 'id:wt-1' }, + method: 'git.continueSequencer', + params: { worktree: 'id:wt-1', operation }, timeoutMs: 30_000 }) - expect(gitSequencer[apiMethod]).not.toHaveBeenCalled() + expect(gitContinueSequencer).not.toHaveBeenCalled() } ) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 887d0e0e534..4806504a618 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -25,9 +25,7 @@ import { abortRuntimeGitMerge as abortRuntimeGitMergeImplementation, abortRuntimeGitRebase as abortRuntimeGitRebaseImplementation, commitRuntimeGit as commitRuntimeGitImplementation, - continueRuntimeGitCherryPick as continueRuntimeGitCherryPickImplementation, - continueRuntimeGitMerge as continueRuntimeGitMergeImplementation, - continueRuntimeGitRebase as continueRuntimeGitRebaseImplementation, + continueRuntimeGitSequencer as continueRuntimeGitSequencerImplementation, fastForwardRuntimeGit as fastForwardRuntimeGitImplementation, fetchRuntimeGit as fetchRuntimeGitImplementation, getRuntimeGitUpstreamStatus as getRuntimeGitUpstreamStatusImplementation, @@ -65,9 +63,7 @@ export const getRuntimeGitHistory = getRuntimeGitHistoryImplementation export const getRuntimeGitConflictOperation = getRuntimeGitConflictOperationImplementation export const abortRuntimeGitMerge = abortRuntimeGitMergeImplementation export const abortRuntimeGitRebase = abortRuntimeGitRebaseImplementation -export const continueRuntimeGitMerge = continueRuntimeGitMergeImplementation -export const continueRuntimeGitRebase = continueRuntimeGitRebaseImplementation -export const continueRuntimeGitCherryPick = continueRuntimeGitCherryPickImplementation +export const continueRuntimeGitSequencer = continueRuntimeGitSequencerImplementation export const getRuntimeGitDiff = getRuntimeGitDiffImplementation export const getRuntimeGitBranchCompare = getRuntimeGitBranchCompareImplementation export const getRuntimeGitCommitCompare = getRuntimeGitCommitCompareImplementation diff --git a/src/renderer/src/runtime/runtime-git-sync-client.ts b/src/renderer/src/runtime/runtime-git-sync-client.ts index 2550805fa7c..0f056c452fe 100644 --- a/src/renderer/src/runtime/runtime-git-sync-client.ts +++ b/src/renderer/src/runtime/runtime-git-sync-client.ts @@ -1,4 +1,5 @@ import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../../shared/git-fork-sync' +import type { GitSequencerOperation } from '../../../shared/git-sequencer-step' import type { GitUpstreamStatus } from '../../../shared/git-status-types' import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../../shared/git-rebase-source' import type { GitPushTarget } from '../../../shared/worktree/types' @@ -41,76 +42,32 @@ export async function abortRuntimeGitRebase(context: RuntimeGitContext): Promise ) } -export async function continueRuntimeGitMerge(context: RuntimeGitContext): Promise { - const target = getActiveRuntimeTarget(context.settings) - if (target.kind === 'local' || !context.worktreeId) { - await window.api.git.continueMerge({ - worktreePath: resolveLocalWorktreePath(context), - connectionId: context.connectionId - }) - return - } - await callSequencerContinueRpc( - target, - 'git.continueMerge', - context.worktreeId, - 'continue a merge' - ) -} - -export async function continueRuntimeGitRebase(context: RuntimeGitContext): Promise { - const target = getActiveRuntimeTarget(context.settings) - if (target.kind === 'local' || !context.worktreeId) { - await window.api.git.continueRebase({ - worktreePath: resolveLocalWorktreePath(context), - connectionId: context.connectionId - }) - return - } - await callSequencerContinueRpc( - target, - 'git.continueRebase', - context.worktreeId, - 'continue a rebase' - ) -} - -export async function continueRuntimeGitCherryPick(context: RuntimeGitContext): Promise { - const target = getActiveRuntimeTarget(context.settings) - if (target.kind === 'local' || !context.worktreeId) { - await window.api.git.continueCherryPick({ - worktreePath: resolveLocalWorktreePath(context), - connectionId: context.connectionId - }) - return - } - await callSequencerContinueRpc( - target, - 'git.continueCherryPick', - context.worktreeId, - 'continue a cherry-pick' - ) -} - -// Why: mixed client/host versions are the normal state; the raw method-not-found -// text would otherwise surface verbatim in the failure toast. -async function callSequencerContinueRpc( - target: Parameters[0], - method: string, - worktreeId: string, - action: string +export async function continueRuntimeGitSequencer( + context: RuntimeGitContext, + operation: GitSequencerOperation ): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.continueSequencer({ + worktreePath: resolveLocalWorktreePath(context), + operation, + connectionId: context.connectionId + }) + return + } try { await callRuntimeRpc( target, - method, - { worktree: toRuntimeWorktreeSelector(worktreeId) }, + 'git.continueSequencer', + { worktree: toRuntimeWorktreeSelector(context.worktreeId), operation }, { timeoutMs: 30_000 } ) } catch (error) { + // Why: mixed client/host versions are the normal state; the raw method-not-found + // text would otherwise surface verbatim in the failure toast. if (isRuntimeMethodNotFoundError(error)) { throw new Error( - `This remote Orca host is running an older version that cannot ${action}. Update Orca on the host, then try again.` + `This remote Orca host is running an older version that cannot continue a ${operation}. Update Orca on the host, then try again.` ) } throw error diff --git a/src/renderer/src/web/preload-api/web-git-api.ts b/src/renderer/src/web/preload-api/web-git-api.ts index 1dc841bea8a..754d8ddafad 100644 --- a/src/renderer/src/web/preload-api/web-git-api.ts +++ b/src/renderer/src/web/preload-api/web-git-api.ts @@ -113,10 +113,13 @@ export function createGitApi(): NonNullable['git']> { worktree: toRuntimeWorktreeSelector(worktree.id) }) }, - continueMerge: ({ worktreePath }) => continueGitOperation('git.continueMerge', worktreePath), - continueRebase: ({ worktreePath }) => continueGitOperation('git.continueRebase', worktreePath), - continueCherryPick: ({ worktreePath }) => - continueGitOperation('git.continueCherryPick', worktreePath), + continueSequencer: async ({ worktreePath, operation }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.continueSequencer', { + worktree: toRuntimeWorktreeSelector(worktree.id), + operation + }) + }, diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => { const file = await resolveRuntimeFilePath(filePath, worktreePath) return callRuntimeResult('git.diff', { @@ -276,11 +279,6 @@ export function createGitApi(): NonNullable['git']> { } } -async function continueGitOperation(method: string, worktreePath: string): Promise { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id) }) -} - export async function mutateGitPath( method: string, worktreePath: string, diff --git a/src/shared/git-sequencer-step.ts b/src/shared/git-sequencer-step.ts new file mode 100644 index 00000000000..0c709c42c2a --- /dev/null +++ b/src/shared/git-sequencer-step.ts @@ -0,0 +1,43 @@ +import type { GitConflictOperation } from './git-status-types' + +/** The operations with a sequencer to continue; `unknown` names no step to run. */ +export type GitSequencerOperation = Exclude + +export type GitSequencerStep = { + args: readonly [string, string] + /** Ref git points at the commit being replayed, and deletes when the sequence ends. */ + marker: string +} + +// Why: everything here predates the Git 2.25 baseline (`merge --continue` 2.12, +// REBASE_HEAD 2.17), so no capability probe or fallback is needed. +const CONTINUE_STEPS: Record = { + merge: { args: ['merge', '--continue'], marker: 'MERGE_HEAD' }, + rebase: { args: ['rebase', '--continue'], marker: 'REBASE_HEAD' }, + 'cherry-pick': { args: ['cherry-pick', '--continue'], marker: 'CHERRY_PICK_HEAD' } +} + +export function isGitSequencerOperation(value: unknown): value is GitSequencerOperation { + return value === 'merge' || value === 'rebase' || value === 'cherry-pick' +} + +export function gitSequencerContinueStep(operation: GitSequencerOperation): GitSequencerStep { + return CONTINUE_STEPS[operation] +} + +/** + * Whether a `--continue` that exited nonzero still moved the sequencer forward. + * + * `--continue` exits nonzero when it DID commit the resolution and then stopped on the + * next commit. The marker naming a DIFFERENT commit is the proof it advanced — unlike + * HEAD, no concurrent commit in the worktree can touch it, so a refused step can never + * masquerade as progress. A marker that cleared is not that proof: git exits 0 when it + * finishes the sequence, so a finished-but-nonzero run means a hook or post-commit step + * failed and must be reported. + */ +export function gitSequencerAdvanced( + markerBefore: string | null, + markerAfter: string | null +): boolean { + return markerBefore !== null && markerAfter !== null && markerAfter !== markerBefore +}