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.
This commit is contained in:
Jinjing
2026-08-31 19:06:00 -07:00
parent c46cb2f3d1
commit 696f0b7a3e
32 changed files with 382 additions and 506 deletions
@@ -6,12 +6,6 @@ const ABORT_METHODS: Record<string, string | undefined> = {
rebase: 'git.abortRebase'
}
const CONTINUE_METHODS: Record<string, string | undefined> = {
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]
)
}
}
+20 -19
View File
@@ -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<void>
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')
})
})
+16 -32
View File
@@ -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<void> {
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<void> {
await runSequencerAction(['merge', '--continue'], 'MERGE_HEAD', worktreePath, options)
}
export async function continueRebase(
worktreePath: string,
options: GitRuntimeOptions = {}
): Promise<void> {
await runSequencerAction(['rebase', '--continue'], 'REBASE_HEAD', worktreePath, options)
}
export async function continueCherryPick(
worktreePath: string,
options: GitRuntimeOptions = {}
): Promise<void> {
await runSequencerAction(['cherry-pick', '--continue'], 'CHERRY_PICK_HEAD', worktreePath, options)
}
@@ -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)
+2 -8
View File
@@ -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 }
+9 -45
View File
@@ -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<void> => {
'git:continueSequencer',
async (
_event,
args: { worktreePath: string; operation: GitSequencerOperation; connectionId?: string }
): Promise<void> => {
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<void> => {
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<void> => {
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)
}
)
+2 -3
View File
@@ -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<GitConflictOperation>
abortMerge(worktreePath: string): Promise<void>
abortRebase(worktreePath: string): Promise<void>
continueMerge(worktreePath: string): Promise<void>
continueRebase(worktreePath: string): Promise<void>
continueCherryPick(worktreePath: string): Promise<void>
continueSequencer(worktreePath: string, operation: GitSequencerOperation): Promise<void>
checkoutBranch(worktreePath: string, branch: string): Promise<void>
listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }>
getBranchCompare(
@@ -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 = {
+4 -19
View File
@@ -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<void> {
await this.sequencerRequest('git.continueMerge', worktreePath, 'continue a merge')
}
async continueRebase(worktreePath: string): Promise<void> {
await this.sequencerRequest('git.continueRebase', worktreePath, 'continue a rebase')
}
async continueCherryPick(worktreePath: string): Promise<void> {
await this.sequencerRequest('git.continueCherryPick', worktreePath, 'continue a cherry-pick')
}
private async sequencerRequest(
method: string,
worktreePath: string,
action: string
): Promise<void> {
async continueSequencer(worktreePath: string, operation: GitSequencerOperation): Promise<void> {
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
@@ -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()
})
})
+2 -6
View File
@@ -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)
+2 -6
View File
@@ -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'] =
@@ -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)
})
+8 -8
View File
@@ -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,
+5 -13
View File
@@ -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',
@@ -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',
+8 -32
View File
@@ -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 }
}
@@ -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<string, unknown>) => Promise<void>,
deviceToken: string
): Promise<void> {
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<string, unknown>[],
mocks: ReturnType<typeof createMobileRpcSurfaceRuntime>['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')
}
@@ -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',
+6 -3
View File
@@ -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<boolean>
abortMerge: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
abortRebase: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
continueMerge: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
continueRebase: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
continueCherryPick: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
continueSequencer: (args: {
worktreePath: string
operation: GitSequencerOperation
connectionId?: string
}) => Promise<void>
fetch: (args: {
worktreePath: string
connectionId?: string
+6 -6
View File
@@ -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<void> =>
ipcRenderer.invoke('git:abortRebase', args),
continueMerge: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('git:continueMerge', args),
continueRebase: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('git:continueRebase', args),
continueCherryPick: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('git:continueCherryPick', args),
continueSequencer: (args: {
worktreePath: string
operation: GitSequencerOperation
connectionId?: string
}): Promise<void> => ipcRenderer.invoke('git:continueSequencer', args),
diff: (args: {
worktreePath: string
filePath: string
+21 -8
View File
@@ -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')
+19 -18
View File
@@ -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<string, unknown>,
args: string[],
marker: string
) {
private async continueSequencer(params: Record<string, unknown>) {
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()
}
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span
className="block min-w-0 max-w-full truncate rounded-sm font-mono text-[10.5px] font-medium text-foreground/90 outline-none focus-visible:ring-1 focus-visible:ring-ring"
tabIndex={0}
aria-label={ariaLabel}
data-testid="source-control-head-identity"
data-operation={operation}
>
{children}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-72 break-all font-mono">
{tooltip}
</TooltipContent>
</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 (
<Tooltip>
<TooltipTrigger asChild>
<span
className="block min-w-0 max-w-full truncate rounded-sm font-mono text-[10.5px] font-medium text-foreground/90 outline-none focus-visible:ring-1 focus-visible:ring-ring"
tabIndex={0}
aria-label={ariaLabel}
data-testid="source-control-head-identity"
data-operation={display.operation}
>
{display.branchName}
<span className="text-amber-600 dark:text-amber-400">{` · ${qualifier}`}</span>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-72 break-all font-mono">
{tooltip}
</TooltipContent>
</Tooltip>
<TruncatedIdentityChip ariaLabel={ariaLabel} tooltip={tooltip} operation={display.operation}>
{display.branchName}
<span className="text-amber-600 dark:text-amber-400">{` · ${qualifier}`}</span>
</TruncatedIdentityChip>
)
}
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span
className="block min-w-0 max-w-full truncate rounded-sm font-mono text-[10.5px] font-medium text-foreground/90 outline-none focus-visible:ring-1 focus-visible:ring-ring"
tabIndex={0}
aria-label={branchAriaLabel}
data-testid="source-control-head-identity"
>
{display.branchName}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-72 break-all font-mono">
{display.branchName}
</TooltipContent>
</Tooltip>
<TruncatedIdentityChip ariaLabel={branchAriaLabel} tooltip={display.branchName}>
{display.branchName}
</TruncatedIdentityChip>
)
}
@@ -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 () => {
@@ -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]
@@ -10,9 +10,7 @@ const PUBLIC_RUNTIME_GIT_CLIENT_FUNCTIONS = [
'cancelRuntimeGenerateCommitMessage',
'cancelRuntimeGeneratePullRequestFields',
'commitRuntimeGit',
'continueRuntimeGitCherryPick',
'continueRuntimeGitMerge',
'continueRuntimeGitRebase',
'continueRuntimeGitSequencer',
'discardRuntimeGitPath',
'discoverRuntimeCommitMessageModels',
'fastForwardRuntimeGit',
@@ -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()
}
)
@@ -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
@@ -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<void> {
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<void> {
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<void> {
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<typeof callRuntimeRpc>[0],
method: string,
worktreeId: string,
action: string
export async function continueRuntimeGitSequencer(
context: RuntimeGitContext,
operation: GitSequencerOperation
): Promise<void> {
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
@@ -113,10 +113,13 @@ export function createGitApi(): NonNullable<Partial<PreloadApi>['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<Partial<PreloadApi>['git']> {
}
}
async function continueGitOperation(method: string, worktreePath: string): Promise<void> {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id) })
}
export async function mutateGitPath(
method: string,
worktreePath: string,
+43
View File
@@ -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<GitConflictOperation, 'unknown'>
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<GitSequencerOperation, GitSequencerStep> = {
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
}