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 +}