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
@@ -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', {