diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 6badf8de49f..17200d09783 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -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() diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 966fbf97ddc..5f6bbd0f4f9 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -459,6 +459,10 @@ export async function detectConflictOperation(worktreePath: string): Promise { + await gitExecFileAsync(['merge', '--abort'], { cwd: worktreePath }) +} + export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index d42fd905fdc..3915cbe5a04 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index b1c1d1a8c59..b3e03ba737b 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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 => { + 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 ( diff --git a/src/main/providers/ssh-git-provider-merge.test.ts b/src/main/providers/ssh-git-provider-merge.test.ts new file mode 100644 index 00000000000..2a6107c8adc --- /dev/null +++ b/src/main/providers/ssh-git-provider-merge.test.ts @@ -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' + }) + }) +}) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index c161c45bf34..a1a1ab4f1cf 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -281,6 +281,10 @@ export class SshGitProvider implements IGitProvider { })) as GitConflictOperation } + async abortMerge(worktreePath: string): Promise { + await this.mux.request('git.abortMerge', { worktreePath }) + } + async getBranchCompare(worktreePath: string, baseRef: string): Promise { return (await this.mux.request('git.branchCompare', { worktreePath, diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 4ce2c43b797..69f80e57cb7 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -172,6 +172,7 @@ export type IGitProvider = { discardChanges(worktreePath: string, filePath: string): Promise bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise detectConflictOperation(worktreePath: string): Promise + abortMerge(worktreePath: string): Promise getBranchCompare(worktreePath: string, baseRef: string): Promise getCommitCompare(worktreePath: string, commitId: string): Promise getUpstreamStatus(worktreePath: string, pushTarget?: GitPushTarget): Promise diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index b7263884b2d..23e3569948c 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -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('../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) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 0425afb400c..92836209206 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -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, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f6088a3c046..4c427565f26 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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'] = diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index c97c0643702..0467f365d54 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 013cf21563b..00549101edd 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -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, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index c6bf5b329ee..bacf4b81dc8 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -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), () => {} ) + 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), + () => {} + ) 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) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 7ed73c10153..f93950c7713 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 599f8160d46..d778a44e7f1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1571,6 +1571,7 @@ export type PreloadApi = { worktreePath: string connectionId?: string }) => Promise + abortMerge: (args: { worktreePath: string; connectionId?: string }) => Promise diff: (args: { worktreePath: string filePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 3f2fc1d14ab..714e5a32ec3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2130,6 +2130,8 @@ const api = { ): Promise => ipcRenderer.invoke('git:history', args), conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:conflictOperation', args), + abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:abortMerge', args), diff: (args: { worktreePath: string filePath: string diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index fd87f31bac1..319acba8322 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -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) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index e4f48f8cc8c..65e7c424d97 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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) { + const worktreePath = params.worktreePath as string + await this.git(['merge', '--abort'], worktreePath) + } + private normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') } diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index 90353907aae..085612f6626 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -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( + + ) + const rebaseMarkup = renderToStaticMarkup( + + ) + + expect(mergeMarkup).toContain('Abort merge') + expect(rebaseMarkup).not.toContain('Abort merge') + }) }) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index bb42a5419a0..8792fe14e33 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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({}) const [commitErrors, setCommitErrors] = useState>({}) const [remoteActionErrors, setRemoteActionErrors] = useState< - Record + Record >({}) // 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>( {} ) + const [abortMergeInFlightByWorktree, setAbortMergeInFlightByWorktree] = useState< + Record + >({}) + 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 => { + 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' && (
- + void handleAbortMerge()} + />
)} @@ -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({ { 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({ Review conflicts + {conflictOperation === 'merge' && onAbortMerge ? ( + + ) : null} ) @@ -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({ {label} + {conflictOperation === 'merge' && onAbortMerge ? ( + + ) : null} ) } diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 800d14ec05e..e7bb7607a77 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -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({ diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts index 8f7dfb4592e..07202822ec3 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts @@ -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 } diff --git a/src/renderer/src/runtime/runtime-git-client-merge.test.ts b/src/renderer/src/runtime/runtime-git-client-merge.test.ts new file mode 100644 index 00000000000..ac49612f37f --- /dev/null +++ b/src/renderer/src/runtime/runtime-git-client-merge.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 9c3ca33f231..48913bd81e7 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -167,6 +167,23 @@ export async function getRuntimeGitConflictOperation( ) } +export async function abortRuntimeGitMerge(context: RuntimeGitContext): Promise { + 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 } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 958b682a944..5cd68cd33c4 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1063,6 +1063,10 @@ function createGitApi(): NonNullable['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', {