feat: add source control action to abort merge (#2092)

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
Leynier Gutiérrez González
2026-05-27 14:59:23 -07:00
committed by GitHub
co-authored by Orca brennanb2025
parent a65f7b4216
commit 659fe79226
25 changed files with 478 additions and 7 deletions
+15
View File
@@ -38,6 +38,7 @@ vi.mock('fs', () => ({
}))
import {
abortMerge,
bulkStageFiles,
bulkDiscardChanges,
bulkUnstageFiles,
@@ -647,6 +648,20 @@ describe('getStatus', () => {
})
})
describe('abortMerge', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})
it('runs git merge --abort in the worktree', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await abortMerge('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['merge', '--abort'], { cwd: '/repo' })
})
})
describe('getStagedCommitContext', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
+4
View File
@@ -459,6 +459,10 @@ export async function detectConflictOperation(worktreePath: string): Promise<Git
return 'unknown'
}
export async function abortMerge(worktreePath: string): Promise<void> {
await gitExecFileAsync(['merge', '--abort'], { cwd: worktreePath })
}
export async function resolveGitDir(worktreePath: string): Promise<string> {
const dotGitPath = path.join(worktreePath, '.git')
+23
View File
@@ -15,6 +15,7 @@ const {
lstatMock,
commitChangesMock,
getStatusMock,
abortMergeMock,
getDiffMock,
getBranchCompareMock,
getBranchDiffMock,
@@ -46,6 +47,7 @@ const {
lstatMock: vi.fn(),
commitChangesMock: vi.fn(),
getStatusMock: vi.fn(),
abortMergeMock: vi.fn(),
getDiffMock: vi.fn(),
getBranchCompareMock: vi.fn(),
getBranchDiffMock: vi.fn(),
@@ -89,6 +91,7 @@ vi.mock('fs/promises', () => ({
vi.mock('../git/status', () => ({
commitChanges: commitChangesMock,
getStatus: getStatusMock,
abortMerge: abortMergeMock,
getDiff: getDiffMock,
getBranchCompare: getBranchCompareMock,
getBranchDiff: getBranchDiffMock,
@@ -579,6 +582,26 @@ describe('registerFilesystemHandlers', () => {
])
})
it('routes abort merge through local and SSH git providers', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
abortMergeMock.mockResolvedValue(undefined)
const sshProvider = {
abortMerge: vi.fn().mockResolvedValue(undefined)
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:abortMerge')!(null, { worktreePath: WORKTREE_FEATURE_PATH })
await handlers.get('git:abortMerge')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1'
})
expect(abortMergeMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH)
expect(sshProvider.abortMerge).toHaveBeenCalledWith('/remote/repo')
})
it('rejects git file paths that escape the selected worktree', async () => {
registerFilesystemHandlers(store as never)
+16
View File
@@ -31,6 +31,7 @@ import {
} from '../../shared/text-search'
import {
getStatus,
abortMerge,
detectConflictOperation,
getDiff,
commitChanges,
@@ -567,6 +568,21 @@ export function registerFilesystemHandlers(
}
)
ipcMain.handle(
'git:abortMerge',
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.abortMerge(args.worktreePath)
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
await abortMerge(worktreePath)
}
)
ipcMain.handle(
'git:diff',
async (
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { SshGitProvider } from './ssh-git-provider'
describe('SshGitProvider merge operations', () => {
it('abortMerge sends git.abortMerge request', async () => {
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.abortMerge('/home/user/repo')
expect(mux.request).toHaveBeenCalledWith('git.abortMerge', {
worktreePath: '/home/user/repo'
})
})
})
+4
View File
@@ -281,6 +281,10 @@ export class SshGitProvider implements IGitProvider {
})) as GitConflictOperation
}
async abortMerge(worktreePath: string): Promise<void> {
await this.mux.request('git.abortMerge', { worktreePath })
}
async getBranchCompare(worktreePath: string, baseRef: string): Promise<GitBranchCompareResult> {
return (await this.mux.request('git.branchCompare', {
worktreePath,
+1
View File
@@ -172,6 +172,7 @@ export type IGitProvider = {
discardChanges(worktreePath: string, filePath: string): Promise<void>
bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise<void>
detectConflictOperation(worktreePath: string): Promise<GitConflictOperation>
abortMerge(worktreePath: string): Promise<void>
getBranchCompare(worktreePath: string, baseRef: string): Promise<GitBranchCompareResult>
getCommitCompare(worktreePath: string, commitId: string): Promise<GitCommitCompareResult>
getUpstreamStatus(worktreePath: string, pushTarget?: GitPushTarget): Promise<GitUpstreamStatus>
+31
View File
@@ -8,6 +8,7 @@ import type * as CommitMessageTextGenerationModule from '../text-generation/comm
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git'
const mocks = vi.hoisted(() => ({
abortMerge: vi.fn(),
getStagedCommitContext: vi.fn(),
generateCommitMessageFromContext: vi.fn(),
resolveCommitMessageSettings: vi.fn(),
@@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('../git/status', async () => ({
...(await vi.importActual<typeof GitStatusModule>('../git/status')),
abortMerge: mocks.abortMerge,
getStagedCommitContext: mocks.getStagedCommitContext
}))
@@ -57,6 +59,7 @@ function makeCommands(worktreePath: string): RuntimeGitCommands {
describe('RuntimeGitCommands', () => {
beforeEach(() => {
mocks.abortMerge.mockReset()
mocks.getStagedCommitContext.mockReset()
mocks.generateCommitMessageFromContext.mockReset()
mocks.resolveCommitMessageSettings.mockReset()
@@ -69,6 +72,34 @@ describe('RuntimeGitCommands', () => {
}
})
it('aborts a local merge through the resolved worktree', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const commands = makeCommands(worktreePath)
mocks.abortMerge.mockResolvedValue(undefined)
await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true })
expect(mocks.abortMerge).toHaveBeenCalledWith(worktreePath)
})
it('aborts a remote merge through the SSH git provider', async () => {
const provider = { abortMerge: vi.fn().mockResolvedValue(undefined) }
mocks.getSshGitProvider.mockReturnValue(provider)
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree('/remote/repo'),
connectionId: 'conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true })
expect(provider.abortMerge).toHaveBeenCalledWith('/remote/repo')
expect(mocks.abortMerge).not.toHaveBeenCalled()
})
it('rejects slash-only git mutation paths before they can target the worktree root', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
+15
View File
@@ -17,6 +17,7 @@ import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-messa
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import { getRemoteFileUrl } from '../git/repo'
import {
abortMerge,
bulkDiscardChanges,
bulkStageFiles,
bulkUnstageFiles,
@@ -148,6 +149,20 @@ export class RuntimeGitCommands {
return detectConflictOperation(target.worktree.path)
}
async abortRuntimeGitMerge(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.abortMerge(target.worktree.path)
return { ok: true }
}
await abortMerge(target.worktree.path)
return { ok: true }
}
async getRuntimeGitDiff(
worktreeSelector: string,
filePath: string,
+2
View File
@@ -2047,6 +2047,8 @@ export class OrcaRuntimeService {
this.gitCommands.getRuntimeGitHistory.bind(this.gitCommands)
getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] =
this.gitCommands.getRuntimeGitConflictOperation.bind(this.gitCommands)
abortRuntimeGitMerge: RuntimeGitCommands['abortRuntimeGitMerge'] =
this.gitCommands.abortRuntimeGitMerge.bind(this.gitCommands)
getRuntimeGitDiff: RuntimeGitCommands['getRuntimeGitDiff'] =
this.gitCommands.getRuntimeGitDiff.bind(this.gitCommands)
getRuntimeGitBranchCompare: RuntimeGitCommands['getRuntimeGitBranchCompare'] =
+3
View File
@@ -197,6 +197,7 @@ describe('git RPC methods', () => {
defaultModelId: 'auto'
}),
cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }),
abortRuntimeGitMerge: vi.fn().mockResolvedValue({ ok: true }),
pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }),
getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3')
} as unknown as OrcaRuntimeService
@@ -216,6 +217,7 @@ describe('git RPC methods', () => {
await dispatcher.dispatch(
makeRequest('git.cancelGenerateCommitMessage', { worktree: 'id:wt-1' })
)
await dispatcher.dispatch(makeRequest('git.abortMerge', { worktree: 'id:wt-1' }))
await dispatcher.dispatch(
makeRequest('git.push', {
worktree: 'id:wt-1',
@@ -237,6 +239,7 @@ describe('git RPC methods', () => {
agentCmdOverrides: { cursor: 'cursor-agent' }
})
expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1')
expect(runtime.abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1')
expect(runtime.pushRuntimeGit).toHaveBeenCalledWith(
'id:wt-1',
true,
+5
View File
@@ -51,6 +51,11 @@ export const GIT_METHODS: RpcMethod[] = [
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.getRuntimeGitConflictOperation(params.worktree)
}),
defineMethod({
name: 'git.abortMerge',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.abortRuntimeGitMerge(params.worktree)
}),
defineMethod({
name: 'git.diff',
params: GitDiff,
+14
View File
@@ -761,6 +761,7 @@ describe('OrcaRuntimeRpcServer', () => {
.fn()
.mockResolvedValue({ hasUpstream: true, ahead: 1, behind: 0 })
const rebaseRuntimeGitFromBase = vi.fn().mockResolvedValue({ ok: true })
const abortRuntimeGitMerge = vi.fn().mockResolvedValue({ ok: true })
const bulkStageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true })
const bulkUnstageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true })
const getRuntimeGitDiff = vi.fn().mockResolvedValue({
@@ -842,6 +843,7 @@ describe('OrcaRuntimeRpcServer', () => {
getRuntimeGitStatus,
getRuntimeGitUpstreamStatus,
rebaseRuntimeGitFromBase,
abortRuntimeGitMerge,
bulkStageRuntimeGitPaths,
bulkUnstageRuntimeGitPaths,
getRuntimeGitDiff,
@@ -1400,6 +1402,16 @@ describe('OrcaRuntimeRpcServer', () => {
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_git_abort_merge',
method: 'git.abortMerge',
deviceToken: mobile.token,
params: { worktree: 'id:wt-1' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_git_bulk_unstage',
@@ -1612,6 +1624,7 @@ describe('OrcaRuntimeRpcServer', () => {
expect.objectContaining({ id: 'req_git_rebase_from_base', ok: true })
)
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_bulk_stage', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_abort_merge', ok: true }))
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_git_bulk_unstage', ok: true })
)
@@ -1646,6 +1659,7 @@ describe('OrcaRuntimeRpcServer', () => {
expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined, undefined)
expect(getRuntimeGitUpstreamStatus).toHaveBeenCalledWith('id:wt-1')
expect(bulkStageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['a.ts', 'b.ts'])
expect(abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1')
expect(bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts'])
expect(openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true)
expect(getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', false, undefined)
+1
View File
@@ -145,6 +145,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'files.open',
'files.openDiff',
'files.read',
'git.abortMerge',
'git.bulkStage',
'git.bulkUnstage',
'git.commit',
+1
View File
@@ -1571,6 +1571,7 @@ export type PreloadApi = {
worktreePath: string
connectionId?: string
}) => Promise<GitConflictOperation>
abortMerge: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
diff: (args: {
worktreePath: string
filePath: string
+2
View File
@@ -2130,6 +2130,8 @@ const api = {
): Promise<GitHistoryResult> => ipcRenderer.invoke('git:history', args),
conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise<unknown> =>
ipcRenderer.invoke('git:conflictOperation', args),
abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('git:abortMerge', args),
diff: (args: {
worktreePath: string
filePath: string
+30
View File
@@ -45,6 +45,7 @@ describe('GitHandler', () => {
expect(methods).toContain('git.unstage')
expect(methods).toContain('git.bulkStage')
expect(methods).toContain('git.bulkUnstage')
expect(methods).toContain('git.abortMerge')
expect(methods).toContain('git.discard')
expect(methods).toContain('git.bulkDiscard')
expect(methods).toContain('git.conflictOperation')
@@ -64,6 +65,35 @@ describe('GitHandler', () => {
expect(methods).toContain('git.isGitRepo')
})
describe('abortMerge', () => {
it('aborts an in-progress merge', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n')
gitCommit(tmpDir, 'initial')
const baseBranch = execFileSync('git', ['branch', '--show-current'], {
cwd: tmpDir,
encoding: 'utf-8',
stdio: 'pipe'
}).trim()
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n')
gitCommit(tmpDir, 'feature change')
execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n')
gitCommit(tmpDir, 'main change')
expect(() =>
execFileSync('git', ['merge', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
).toThrow()
await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).resolves.toBeUndefined()
await dispatcher.callRequest('git.abortMerge', { worktreePath: tmpDir })
await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).rejects.toThrow()
await expect(fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8')).resolves.toBe('main\n')
})
})
describe('renameCurrentBranch', () => {
it('renames only the checked-out branch through the narrow RPC', async () => {
gitInit(tmpDir)
+6
View File
@@ -61,6 +61,7 @@ export class GitHandler {
this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p))
this.dispatcher.onRequest('git.bulkStage', (p) => this.bulkStage(p))
this.dispatcher.onRequest('git.bulkUnstage', (p) => this.bulkUnstage(p))
this.dispatcher.onRequest('git.abortMerge', (p) => this.abortMerge(p))
this.dispatcher.onRequest('git.discard', (p) => this.discard(p))
this.dispatcher.onRequest('git.bulkDiscard', (p) => this.bulkDiscard(p))
this.dispatcher.onRequest('git.conflictOperation', (p) => this.conflictOperation(p))
@@ -183,6 +184,11 @@ export class GitHandler {
}
}
private async abortMerge(params: Record<string, unknown>) {
const worktreePath = params.worktreePath as string
await this.git(['merge', '--abort'], worktreePath)
}
private normalizeGitPathForCompare(filePath: string): string {
return filePath.replace(/\\/g, '/').replace(/\/+$/, '')
}
@@ -333,4 +333,30 @@ describe('ConflictSummaryCard', () => {
expect(markup.indexOf('Resolve with AI')).toBeLessThan(markup.indexOf('Review conflicts'))
})
it('shows Abort merge only for merge conflicts', () => {
const mergeMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="merge"
unresolvedCount={1}
isResolvingWithAI={false}
onAbortMerge={vi.fn()}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
const rebaseMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="rebase"
unresolvedCount={1}
isResolvingWithAI={false}
onAbortMerge={vi.fn()}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
expect(mergeMarkup).toContain('Abort merge')
expect(rebaseMarkup).not.toContain('Abort merge')
})
})
@@ -101,6 +101,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { BaseRefPicker } from '@/components/settings/BaseRefPicker'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { formatDiffComment, formatDiffComments } from '@/lib/diff-comments-format'
import { getDiffCommentLineLabel, getDiffCommentSource } from '@/lib/diff-comment-compat'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
@@ -115,6 +116,7 @@ import {
} from '@/components/editor/editor-autosave'
import { getConnectionId } from '@/lib/connection-context'
import {
abortRuntimeGitMerge,
bulkDiscardRuntimeGitPaths,
bulkStageRuntimeGitPaths,
bulkUnstageRuntimeGitPaths,
@@ -160,7 +162,7 @@ import {
import { hasExpandedCommitFailureDetails, summarizeCommitFailure } from './commit-failure-summary'
export type SourceControlScope = 'all' | 'uncommitted'
type RemoteActionError = { kind: RemoteOpKind; message: string }
type SourceControlActionError = { kind: RemoteOpKind | 'abort_merge'; message: string }
const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = []
const EMPTY_BRANCH_CHANGE_ENTRIES: GitBranchChangeEntry[] = []
@@ -1119,7 +1121,7 @@ function SourceControlInner(): React.JSX.Element {
const [commitDrafts, setCommitDrafts] = useState<CommitDraftsByWorktree>({})
const [commitErrors, setCommitErrors] = useState<Record<string, string | null>>({})
const [remoteActionErrors, setRemoteActionErrors] = useState<
Record<string, RemoteActionError | null>
Record<string, SourceControlActionError | null>
>({})
// Why: keep commit-in-flight state per-worktree. A single boolean would be
// cleared when the user switched worktrees, letting them double-click Commit
@@ -1128,6 +1130,11 @@ function SourceControlInner(): React.JSX.Element {
const [commitInFlightByWorktree, setCommitInFlightByWorktree] = useState<Record<string, boolean>>(
{}
)
const [abortMergeInFlightByWorktree, setAbortMergeInFlightByWorktree] = useState<
Record<string, boolean>
>({})
const isAbortingMerge = abortMergeInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const confirmAction = useConfirmationDialog()
const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false
// Why: parallel state to commit. Same per-worktree shape so navigating between
// worktrees mid-generation never silently cancels the in-flight request.
@@ -1677,6 +1684,7 @@ function SourceControlInner(): React.JSX.Element {
setCommitErrors((prev) => pruneRecord(prev))
setRemoteActionErrors((prev) => pruneRecord(prev))
setCommitInFlightByWorktree((prev) => pruneRecord(prev))
setAbortMergeInFlightByWorktree((prev) => pruneRecord(prev))
setGenerateInFlightByWorktree((prev) => pruneRecord(prev))
setGenerateErrors((prev) => pruneRecord(prev))
setGitHistoryByWorktree((prev) => pruneRecord(prev))
@@ -2013,6 +2021,53 @@ function SourceControlInner(): React.JSX.Element {
]
)
const handleAbortMerge = useCallback(async (): Promise<void> => {
if (!activeWorktreeId || !worktreePath || conflictOperation !== 'merge' || isAbortingMerge) {
return
}
const confirmed = await confirmAction({
title: 'Abort merge?',
description:
'This cancels the merge in progress and can discard conflict resolutions made during this merge.',
confirmLabel: 'Abort merge',
confirmVariant: 'destructive'
})
if (!confirmed) {
return
}
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
setAbortMergeInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
try {
await abortRuntimeGitMerge({
settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
})
await refreshActiveGitStatusAfterMutation()
void refreshGitHistoryRef.current()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to abort merge'
toast.error('Abort merge failed', { description: message })
setRemoteActionErrors((prev) => ({
...prev,
[activeWorktreeId]: { kind: 'abort_merge', message }
}))
} finally {
setAbortMergeInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
}
}, [
activeWorktreeId,
confirmAction,
conflictOperation,
isAbortingMerge,
refreshActiveGitStatusAfterMutation,
worktreePath
])
// Why: compound actions must commit first and only run the follow-up remote
// op when the commit succeeds. handleCommit's return value carries that
// signal — a failure leaves commitError populated and short-circuits here
@@ -2557,7 +2612,7 @@ function SourceControlInner(): React.JSX.Element {
hasMessage: commitMessage.trim().length > 0,
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
isCommitting,
isRemoteOperationActive,
isRemoteOperationActive: isRemoteOperationActive || isAbortingMerge,
upstreamStatus: remoteStatus,
prState: hostedReview?.state ?? null,
isPRStateLoading: isHostedReviewStateLoading,
@@ -2575,6 +2630,7 @@ function SourceControlInner(): React.JSX.Element {
hasUnstagedChanges,
hasPartiallyStagedChanges,
isCommitting,
isAbortingMerge,
isRemoteOperationActive,
inFlightRemoteOpKind,
hostedReviewCreation,
@@ -2596,7 +2652,8 @@ function SourceControlInner(): React.JSX.Element {
hasMessage: commitMessage.trim().length > 0,
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
isCommitting,
isRemoteOperationActive,
isRemoteOperationActive: isRemoteOperationActive || isAbortingMerge,
conflictOperation,
upstreamStatus: remoteStatus,
prState: hostedReview?.state ?? null,
isPRStateLoading: isHostedReviewStateLoading,
@@ -2613,6 +2670,8 @@ function SourceControlInner(): React.JSX.Element {
hasUnstagedChanges,
hasPartiallyStagedChanges,
isCommitting,
conflictOperation,
isAbortingMerge,
isRemoteOperationActive,
inFlightRemoteOpKind,
hostedReviewCreation,
@@ -2647,6 +2706,9 @@ function SourceControlInner(): React.JSX.Element {
case 'commit_sync':
void runCompoundCommitAction('sync')
return
case 'abort_merge':
void handleAbortMerge()
return
case 'create_pr':
void handleCreatePullRequest()
return
@@ -2673,6 +2735,7 @@ function SourceControlInner(): React.JSX.Element {
[
handleCommit,
handleCreatePullRequest,
handleAbortMerge,
isCreatingPr,
prGenerating,
runCompoundCommitAction,
@@ -3926,6 +3989,8 @@ function SourceControlInner(): React.JSX.Element {
conflictOperation={conflictOperation}
unresolvedCount={unresolvedConflictReviewEntries.length}
isResolvingWithAI={isLaunchingConflictAgent}
isAbortingMerge={isAbortingMerge}
onAbortMerge={() => void handleAbortMerge()}
onResolveWithAI={() => {
void handleResolveConflictsWithAI()
}}
@@ -3949,7 +4014,11 @@ function SourceControlInner(): React.JSX.Element {
ConflictSummaryCard handles the "has conflicts" case above. */}
{unresolvedConflictReviewEntries.length === 0 && conflictOperation !== 'unknown' && (
<div className="px-3 pb-2">
<OperationBanner conflictOperation={conflictOperation} />
<OperationBanner
conflictOperation={conflictOperation}
isAbortingMerge={isAbortingMerge}
onAbortMerge={() => void handleAbortMerge()}
/>
</div>
)}
@@ -4042,7 +4111,7 @@ function SourceControlInner(): React.JSX.Element {
generateError={generateError}
stagedCount={grouped.staged.length}
hasUnresolvedConflicts={unresolvedConflicts.length > 0}
isRemoteOperationActive={isRemoteOperationActive}
isRemoteOperationActive={isRemoteOperationActive || isAbortingMerge}
inFlightRemoteOpKind={inFlightRemoteOpKind}
primaryAction={primaryAction}
dropdownItems={dropdownItems}
@@ -4763,6 +4832,7 @@ function PullRequestComposer({
key={entry.kind}
disabled={entry.disabled}
title={entry.title}
variant={entry.variant}
onSelect={(event) => {
if (entry.disabled) {
event.preventDefault()
@@ -5205,6 +5275,7 @@ export function CommitArea({
<DropdownMenuItem
disabled={entry.disabled}
title={entry.title}
variant={entry.variant}
className="w-full"
onSelect={(event) => {
if (entry.disabled) {
@@ -5692,12 +5763,16 @@ export function ConflictSummaryCard({
conflictOperation,
unresolvedCount,
isResolvingWithAI,
isAbortingMerge = false,
onAbortMerge,
onResolveWithAI,
onReview
}: {
conflictOperation: GitConflictOperation
unresolvedCount: number
isResolvingWithAI: boolean
isAbortingMerge?: boolean
onAbortMerge?: () => void
onResolveWithAI: () => void
onReview: () => void
}): React.JSX.Element {
@@ -5750,6 +5825,19 @@ export function ConflictSummaryCard({
<GitMerge className="size-3.5" />
Review conflicts
</Button>
{conflictOperation === 'merge' && onAbortMerge ? (
<Button
type="button"
variant="destructive"
size="sm"
className="mt-1.5 h-7 w-full text-xs"
disabled={isResolvingWithAI || isAbortingMerge}
onClick={onAbortMerge}
>
{isAbortingMerge ? <RefreshCw className="size-3.5 animate-spin" /> : null}
Abort merge
</Button>
) : null}
</div>
</div>
)
@@ -5761,9 +5849,13 @@ export function ConflictSummaryCard({
// user needs to see the operation state so they know the worktree is mid-rebase
// and that they should run `git rebase --continue` or `--abort`.
function OperationBanner({
conflictOperation
conflictOperation,
isAbortingMerge = false,
onAbortMerge
}: {
conflictOperation: GitConflictOperation
isAbortingMerge?: boolean
onAbortMerge?: () => void
}): React.JSX.Element {
const label =
conflictOperation === 'merge'
@@ -5782,6 +5874,19 @@ function OperationBanner({
<Icon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span className="text-xs font-medium text-foreground">{label}</span>
</div>
{conflictOperation === 'merge' && onAbortMerge ? (
<Button
type="button"
variant="destructive"
size="sm"
className="mt-2 h-7 w-full text-xs"
disabled={isAbortingMerge}
onClick={onAbortMerge}
>
{isAbortingMerge ? <RefreshCw className="size-3.5 animate-spin" /> : null}
Abort merge
</Button>
) : null}
</div>
)
}
@@ -214,6 +214,46 @@ describe('resolveDropdownItems', () => {
}
})
it('shows a destructive Abort merge item only while a merge is in progress', () => {
const mergeItems = resolveDropdownItems(
inputs({
conflictOperation: 'merge',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
})
)
const mergeByKind = Object.fromEntries(
mergeItems.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(mergeByKind.abort_merge).toMatchObject({
label: 'Abort merge',
title: 'Abort the merge in progress',
disabled: false,
variant: 'destructive'
})
for (const conflictOperation of ['unknown', 'rebase', 'cherry-pick'] as const) {
const items = resolveDropdownItems(inputs({ conflictOperation }))
expect(items.some((entry) => entry.kind === 'abort_merge')).toBe(false)
}
})
it('disables Abort merge while another action is busy', () => {
const items = resolveDropdownItems(
inputs({
conflictOperation: 'merge',
isRemoteOperationActive: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
})
)
const abortMerge = items.find((entry) => entry.kind === 'abort_merge')
expect(abortMerge).toMatchObject({
disabled: true,
title: 'Operation in progress…'
})
})
it('locks every item while a pull request operation is running', () => {
const items = resolveDropdownItems(
inputs({
@@ -2,9 +2,11 @@
// Why: split from source-control-primary-action because the primary and dropdown are independent derivations with different priority ladders; together they exceed the max-lines budget and tangle unrelated concerns.
import type { PrimaryActionInputs } from './source-control-primary-action'
import type { GitConflictOperation } from '../../../../shared/types'
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
export type DropdownActionInputs = PrimaryActionInputs & {
conflictOperation?: GitConflictOperation
isPullRequestOperationActive?: boolean
rebaseBaseRef?: string | null
}
@@ -13,6 +15,7 @@ export type DropdownActionKind =
| 'commit'
| 'commit_push'
| 'commit_sync'
| 'abort_merge'
| 'create_pr'
| 'push_create_pr'
| 'push'
@@ -28,6 +31,7 @@ export type DropdownItem = {
title: string
disabled: boolean
hint?: string
variant?: 'default' | 'destructive'
}
export type DropdownSeparator = { kind: 'separator' }
@@ -86,6 +90,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
prState,
isPRStateLoading,
hostedReviewCreation,
conflictOperation = 'unknown',
branchCommitsAhead,
rebaseBaseRef,
isPullRequestOperationActive = false
@@ -414,6 +419,18 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
fetchItem,
publishItem
]
if (conflictOperation === 'merge') {
entries.push(
{ kind: 'separator' },
{
kind: 'abort_merge',
label: 'Abort merge',
title: globalBusy ? 'Operation in progress…' : 'Abort the merge in progress',
disabled: globalBusy,
variant: 'destructive'
}
)
}
if (!isPullRequestOperationActive) {
return entries
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from './runtime-compatibility-test-fixture'
import { abortRuntimeGitMerge } from './runtime-git-client'
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
const gitAbortMerge = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
const runtimeCall = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
gitAbortMerge.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
runtimeCall.mockReset()
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
})
vi.stubGlobal('window', {
api: {
git: { abortMerge: gitAbortMerge },
runtime: { call: runtimeCall },
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
}
})
})
describe('runtime git client merge operations', () => {
it('uses local git IPC when no remote runtime is active', async () => {
gitAbortMerge.mockResolvedValue(undefined)
await abortRuntimeGitMerge({
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'wt-1',
worktreePath: '/repo'
})
expect(gitAbortMerge).toHaveBeenCalledWith({ connectionId: undefined, worktreePath: '/repo' })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('routes abort merge through the active runtime', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { success: true },
_meta: { runtimeId: 'remote-runtime' }
})
await abortRuntimeGitMerge({
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/repo'
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'git.abortMerge',
params: { worktree: 'wt-1' },
timeoutMs: 30_000
})
expect(gitAbortMerge).not.toHaveBeenCalled()
})
})
@@ -167,6 +167,23 @@ export async function getRuntimeGitConflictOperation(
)
}
export async function abortRuntimeGitMerge(context: RuntimeGitContext): Promise<void> {
const target = getActiveRuntimeTarget(context.settings)
if (target.kind === 'local' || !context.worktreeId) {
await window.api.git.abortMerge({
worktreePath: context.worktreePath,
connectionId: context.connectionId
})
return
}
await callRuntimeRpc(
target,
'git.abortMerge',
{ worktree: context.worktreeId },
{ timeoutMs: 30_000 }
)
}
export async function getRuntimeGitDiff(
context: RuntimeGitContext,
args: { filePath: string; staged: boolean; compareAgainstHead?: boolean }
+4
View File
@@ -1063,6 +1063,10 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.conflictOperation', { worktree: worktree.id })
},
abortMerge: async ({ worktreePath }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.abortMerge', { worktree: worktree.id })
},
diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => {
const file = await resolveRuntimeFilePath(filePath, worktreePath)
return callRuntimeResult('git.diff', {