From dc9928108597fcf35690a76f5616d20cfea4bed7 Mon Sep 17 00:00:00 2001
From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
Date: Fri, 30 Oct 2026 03:18:48 +0000
Subject: [PATCH] feat(source-control): resolve merge conflicts and complete
merges from the phone (#36160)
* feat(source-control): resolve conflicts with one host operation shared by desktop and mobile
* feat(mobile): resolve merge conflicts and complete merges from Source Control
* test(mobile): pin Complete merge refusals and the older-desktop message
* fix(source-control): only resolve paths Git still reports as conflicted
* fix(source-control): refuse to keep a missing file and serialize conflict resolution per worktree
* fix(source-control): resolve only the exact conflicted path
* fix(source-control): match conflicted paths in Git's separator form on Windows and WSL
* fix(source-control): accept only the resolutions the live conflict offers
* fix(source-control): refuse to keep a both-modified file that is gone from disk
* feat(mobile): translate the merge conflict actions
* test(runtime): keep the mobile allowlist test under its line cap
* fix(mobile): let VoiceOver reach conflict action buttons
---
mobile/src/i18n/locales/en.json | 12 ++-
.../MobileSourceControlBranchCard.tsx | 22 +++++
.../MobileSourceControlContent.test.tsx | 2 +
.../MobileSourceControlContent.tsx | 3 +
.../MobileSourceControlFileList.tsx | 1 +
.../MobileSourceControlFileList.web.test.tsx | 5 +-
.../MobileSourceControlFileRows.tsx | 51 +++++++++-
.../MobileSourceControlPanel.tsx | 13 ++-
.../src/source-control/mobile-git-status.ts | 8 +-
.../mobile-source-control-conflict-actions.ts | 25 +++++
.../mobile-source-control-list-styles.ts | 19 ++++
.../mobile-source-control-screen-state.ts | 6 ++
.../mobile-source-control-styles.ts | 15 +++
.../source-control-status-retry.test.tsx | 2 +
.../use-mobile-conflict-runners.test.ts | 80 ++++++++++++++++
.../use-mobile-conflict-runners.ts | 94 +++++++++++++++++++
.../use-mobile-source-control-runners.ts | 15 +--
.../complete-merge-real-git.test.ts | 81 ++++++++++++++--
.../git/source-control/git-pathspec.test.ts | 18 +++-
src/main/git/source-control/git-pathspec.ts | 11 +++
src/main/git/source-control/staging.ts | 56 ++++++++++-
src/main/git/status.ts | 1 +
.../filesystem-git-index-handlers.ts | 34 +++++++
src/main/providers/git-provider-contract.ts | 6 ++
src/main/providers/local-git-provider.ts | 3 +
src/main/runtime/orca-runtime-git.ts | 2 +
src/main/runtime/rpc/methods/git.ts | 8 ++
.../runtime/runtime-git-command-surface.ts | 2 +
.../runtime/runtime-git-staging-commands.ts | 16 ++++
.../runtime-rpc-mobile-method-routing.ts | 2 +
src/preload/api/git-bridge.ts | 7 ++
src/preload/api/git-operation-api.ts | 7 ++
.../commit/use-entry-mutations.ts | 56 +++++------
src/renderer/src/i18n/locales/en.json | 3 +
.../runtime-git-working-tree-client.ts | 38 ++++++++
.../src/web/preload-api/web-git-api.ts | 4 +-
.../src/web/preload-api/web-git-mutations.ts | 14 +++
src/shared/rpc-contract/git-params.ts | 5 +
.../rpc-params-catalog.generated.ts | 2 +
src/shared/source-control-entry-actions.ts | 3 +-
40 files changed, 689 insertions(+), 63 deletions(-)
create mode 100644 mobile/src/source-control/mobile-source-control-conflict-actions.ts
create mode 100644 mobile/src/source-control/use-mobile-conflict-runners.test.ts
create mode 100644 mobile/src/source-control/use-mobile-conflict-runners.ts
diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json
index 2234157c069..8e3a130a11d 100644
--- a/mobile/src/i18n/locales/en.json
+++ b/mobile/src/i18n/locales/en.json
@@ -3034,7 +3034,9 @@
"cherryPickState": "cherry-pick",
"changedCount": "{{count}} changed",
"stagedCount": "{{count}} staged",
- "branchCount": "{{count}} on branch"
+ "branchCount": "{{count}} on branch",
+ "completingMerge": "Completing…",
+ "completeMerge": "Complete merge"
},
"content": {
"reconnecting": "Reconnecting to desktop...",
@@ -3314,6 +3316,14 @@
"planModelUnavailable": "Model \"{{model}}\" is not available for {{label}}.",
"planModelWithoutThinking": "Model \"{{model}}\" does not support a thinking effort level.",
"planThinkingLevelInvalid": "Thinking level \"{{level}}\" is not valid for {{model}}."
+ },
+ "conflictActions": {
+ "resolveFile": "{{action}}: {{path}}",
+ "needsNewerHost": "Update Orca on the computer running this workspace to resolve merge conflicts from your phone.",
+ "markResolved": "Mark as resolved",
+ "keepFile": "Keep file",
+ "keepDeletion": "Keep deletion",
+ "completeMergeFailed": "Complete merge failed"
}
},
"newWorkspace": {
diff --git a/mobile/src/source-control/MobileSourceControlBranchCard.tsx b/mobile/src/source-control/MobileSourceControlBranchCard.tsx
index 44862bcddb9..34ac6aec694 100644
--- a/mobile/src/source-control/MobileSourceControlBranchCard.tsx
+++ b/mobile/src/source-control/MobileSourceControlBranchCard.tsx
@@ -18,6 +18,9 @@ type Props = {
conflictBusy: boolean
conflictAborting: boolean
onAbortConflict: (operation: string) => void
+ /** Set once a paused merge has no unresolved conflicts left. */
+ onCompleteMerge: (() => void) | null
+ completingMerge: boolean
prChip: MobilePrChipSummary | null
onOpenPr: () => void
}
@@ -32,6 +35,8 @@ export function MobileSourceControlBranchCard({
conflictBusy,
conflictAborting,
onAbortConflict,
+ onCompleteMerge,
+ completingMerge,
prChip,
onOpenPr
}: Props) {
@@ -78,6 +83,23 @@ export function MobileSourceControlBranchCard({
{showConflict ? (
{conflictLabel}
+ {onCompleteMerge ? (
+ [
+ styles.completeMergeButton,
+ conflictBusy && styles.abortButtonDisabled,
+ pressed && !conflictBusy && styles.abortPressed
+ ]}
+ disabled={conflictBusy}
+ onPress={onCompleteMerge}
+ >
+
+ {completingMerge
+ ? t('sourceControl.branchCard.completingMerge', 'Completing…')
+ : t('sourceControl.branchCard.completeMerge', 'Complete merge')}
+
+
+ ) : null}
{conflictOperation === 'merge' || conflictOperation === 'rebase' ? (
[
diff --git a/mobile/src/source-control/MobileSourceControlContent.test.tsx b/mobile/src/source-control/MobileSourceControlContent.test.tsx
index bdfa6e29580..dc14c5145f0 100644
--- a/mobile/src/source-control/MobileSourceControlContent.test.tsx
+++ b/mobile/src/source-control/MobileSourceControlContent.test.tsx
@@ -196,6 +196,8 @@ function initialState(): MobileSourceControlContentState {
openHistory: noop,
checkoutBranch: unusedAsync,
abortConflictOperation: unusedAsync,
+ resolveConflict: unusedAsync,
+ completeMerge: unusedAsync,
runActionSheetCommit: unusedAsync,
runActionSheetCommitSequence: unusedAsync,
runActionSheetCommitSync: unusedAsync,
diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx
index 8d1434ba1e0..5df7d9d7eff 100644
--- a/mobile/src/source-control/MobileSourceControlContent.tsx
+++ b/mobile/src/source-control/MobileSourceControlContent.tsx
@@ -50,6 +50,7 @@ export function MobileSourceControlContent({ state, primaryAction }: Props) {
cancelGenerateCommitMessage,
openFile,
openBranchDiff,
+ resolveConflict,
runGitAction
} = state
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
@@ -67,6 +68,7 @@ export function MobileSourceControlContent({ state, primaryAction }: Props) {
state={{
sections,
openFile,
+ resolveConflict,
runGitAction,
setDiscardTarget,
shouldShowBranchCompareSection: state.shouldShowBranchCompareSection,
@@ -84,6 +86,7 @@ export function MobileSourceControlContent({ state, primaryAction }: Props) {
[
sections,
openFile,
+ resolveConflict,
runGitAction,
setDiscardTarget,
state.shouldShowBranchCompareSection,
diff --git a/mobile/src/source-control/MobileSourceControlFileList.tsx b/mobile/src/source-control/MobileSourceControlFileList.tsx
index 230320c8af7..b42fe1951ad 100644
--- a/mobile/src/source-control/MobileSourceControlFileList.tsx
+++ b/mobile/src/source-control/MobileSourceControlFileList.tsx
@@ -34,6 +34,7 @@ export type FileListState = Pick<
| 'openingBranchPath'
| 'openFile'
| 'openBranchDiff'
+ | 'resolveConflict'
| 'runGitAction'
| 'setDiscardTarget'
>
diff --git a/mobile/src/source-control/MobileSourceControlFileList.web.test.tsx b/mobile/src/source-control/MobileSourceControlFileList.web.test.tsx
index c9c939eb70c..795f2ddc478 100644
--- a/mobile/src/source-control/MobileSourceControlFileList.web.test.tsx
+++ b/mobile/src/source-control/MobileSourceControlFileList.web.test.tsx
@@ -58,7 +58,9 @@ function fileListState(count: number, dirty: boolean): FileListState {
canDiscard: true,
stageActionId: 'stage:src/uncommitted.ts',
unstageActionId: 'unstage:src/uncommitted.ts',
- discardActionId: 'discard:src/uncommitted.ts'
+ discardActionId: 'discard:src/uncommitted.ts',
+ conflictResolutions: [],
+ resolveActionId: 'resolve:src/uncommitted.ts'
}
]
}
@@ -75,6 +77,7 @@ function fileListState(count: number, dirty: boolean): FileListState {
openFile: vi.fn(async () => {}),
openBranchDiff: vi.fn(async () => {}),
runGitAction: vi.fn(async () => true),
+ resolveConflict: vi.fn(async () => {}),
setDiscardTarget: vi.fn()
}
}
diff --git a/mobile/src/source-control/MobileSourceControlFileRows.tsx b/mobile/src/source-control/MobileSourceControlFileRows.tsx
index 0a94a348882..8c8691d640b 100644
--- a/mobile/src/source-control/MobileSourceControlFileRows.tsx
+++ b/mobile/src/source-control/MobileSourceControlFileRows.tsx
@@ -14,6 +14,7 @@ import {
} from './mobile-source-control-screen-state'
import type { MobileSourceControlState } from './use-mobile-source-control-state'
import { styles } from './mobile-source-control-styles'
+import { mobileConflictResolutionLabel } from './mobile-source-control-conflict-actions'
type RowState = Pick<
MobileSourceControlState,
@@ -21,6 +22,7 @@ type RowState = Pick<
| 'openingPath'
| 'openingBranchPath'
| 'openFile'
+ | 'resolveConflict'
| 'runGitAction'
| 'setDiscardTarget'
>
@@ -47,27 +49,39 @@ function conflictKindLabel(kind: GitConflictKind | undefined, t: Translate): str
}
export function makeRenderFileRow(state: RowState, t: Translate) {
- const { busyAction, openingPath, openingBranchPath, openFile, runGitAction, setDiscardTarget } =
- state
+ const {
+ busyAction,
+ openingPath,
+ openingBranchPath,
+ openFile,
+ resolveConflict,
+ runGitAction,
+ setDiscardTarget
+ } = state
return function renderFileRow({ item }: { item: MobileGitStatusEntryView }) {
const rowBusy =
busyAction === item.stageActionId ||
busyAction === item.unstageActionId ||
busyAction === item.discardActionId ||
+ busyAction === item.resolveActionId ||
openingPath === item.path
const rowDisabled =
!item.canOpen || busyAction !== null || openingPath !== null || openingBranchPath !== null
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
+ const hasConflictActions = item.conflictResolutions.length > 0
return (
[
styles.fileRow,
pressed && item.canOpen && styles.fileRowPressed,
- rowDisabled && styles.fileRowDisabled,
- !item.canOpen && styles.fileRowUnavailable
+ // Conflict rows cannot open but carry live actions; their buttons dim themselves while busy.
+ rowDisabled && !hasConflictActions && styles.fileRowDisabled,
+ !item.canOpen && !hasConflictActions && styles.fileRowUnavailable
]}
onPress={() => void openFile(item)}
disabled={rowDisabled}
+ // An accessible row hides nested buttons from iOS VoiceOver; conflict rows cannot open anyway.
+ accessible={!hasConflictActions}
accessibilityLabel={t(
'sourceControl.fileRows.openChangedFile',
'Open changed file {{path}}',
@@ -102,6 +116,35 @@ export function makeRenderFileRow(state: RowState, t: Translate) {
{conflictKindLabel(item.conflictKind, t)}
) : null}
+ {!rowBusy && hasConflictActions ? (
+
+ {item.conflictResolutions.map((resolution) => (
+ [
+ styles.conflictActionButton,
+ ioBusy && styles.iconButtonDisabled,
+ pressed && styles.iconButtonPressed
+ ]}
+ disabled={ioBusy}
+ onPress={() => void resolveConflict(item, resolution)}
+ hitSlop={4}
+ accessibilityLabel={t(
+ 'sourceControl.conflictActions.resolveFile',
+ '{{action}}: {{path}}',
+ {
+ action: mobileConflictResolutionLabel(resolution, t),
+ path: item.path
+ }
+ )}
+ >
+
+ {mobileConflictResolutionLabel(resolution, t)}
+
+
+ ))}
+
+ ) : null}
{rowBusy ? (
diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx
index a6537f72453..bec75f67625 100644
--- a/mobile/src/source-control/MobileSourceControlPanel.tsx
+++ b/mobile/src/source-control/MobileSourceControlPanel.tsx
@@ -21,6 +21,7 @@ import { styles } from './mobile-source-control-styles'
import { hubStyles } from './mobile-source-control-hub-styles'
import type { SourceControlHubTab } from './mobile-source-control-hub-tab'
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
+import { MOBILE_COMPLETE_MERGE_ACTION_ID } from './mobile-source-control-conflict-actions'
import { isMobileConflictAborting } from './mobile-source-control-conflict-abort'
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state'
@@ -116,7 +117,8 @@ export function MobileSourceControlPanel({
unstagedCount,
stagedCount,
branchEntries,
- abortConflictOperation
+ abortConflictOperation,
+ completeMerge
} = state
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const ready = screenState.kind === 'ready'
@@ -310,6 +312,15 @@ export function MobileSourceControlPanel({
conflictBusy={busyAction !== null}
conflictAborting={conflictAborting}
onAbortConflict={(operation) => void abortConflictOperation(operation)}
+ // Failures render in the Changes body, so offer completion only where they can be read.
+ onCompleteMerge={
+ activeTab === 'changes' &&
+ conflictOperation === 'merge' &&
+ !primaryActionArgs.hasUnresolvedConflicts
+ ? () => void completeMerge()
+ : null
+ }
+ completingMerge={busyAction === MOBILE_COMPLETE_MERGE_ACTION_ID}
prChip={prChip}
onOpenPr={openPrTab}
/>
diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts
index 2982d208f9e..5139d1fc46f 100644
--- a/mobile/src/source-control/mobile-git-status.ts
+++ b/mobile/src/source-control/mobile-git-status.ts
@@ -11,7 +11,9 @@ import { getUnstageAllPaths } from '../../../src/shared/source-control-discard-a
import {
canDiscardStatusEntry,
canStageStatusEntry,
- canUnstageStatusEntry
+ canUnstageStatusEntry,
+ getConflictResolutions,
+ type ConflictResolution
} from '../../../src/shared/source-control-entry-actions'
import type { SourceControlGroupOrder } from '../../../src/shared/ui-chrome-types'
import type { RpcResponse } from '../transport/types'
@@ -93,6 +95,10 @@ export function isMobileGitDiscardableEntry(entry: MobileGitStatusEntry): boolea
return hasKnownArea(entry) && canDiscardStatusEntry(entry)
}
+export function getMobileConflictResolutions(entry: MobileGitStatusEntry): ConflictResolution[] {
+ return hasKnownArea(entry) ? getConflictResolutions(entry) : []
+}
+
export function isMobileGitUnstageableEntry(entry: MobileGitStatusEntry): boolean {
return hasKnownArea(entry) && canUnstageStatusEntry(entry)
}
diff --git a/mobile/src/source-control/mobile-source-control-conflict-actions.ts b/mobile/src/source-control/mobile-source-control-conflict-actions.ts
new file mode 100644
index 00000000000..3c3b8ea4d5a
--- /dev/null
+++ b/mobile/src/source-control/mobile-source-control-conflict-actions.ts
@@ -0,0 +1,25 @@
+import type { ConflictResolution } from '../../../src/shared/source-control-entry-actions'
+import { translate, type Translate } from '../i18n/mobile-i18n'
+
+export const MOBILE_COMPLETE_MERGE_ACTION_ID = 'complete-merge'
+
+export function mobileConflictNeedsNewerHostMessage(): string {
+ return translate(
+ 'sourceControl.conflictActions.needsNewerHost',
+ 'Update Orca on the computer running this workspace to resolve merge conflicts from your phone.'
+ )
+}
+
+export function mobileConflictResolutionLabel(
+ resolution: ConflictResolution,
+ t: Translate
+): string {
+ switch (resolution) {
+ case 'mark_resolved':
+ return t('sourceControl.conflictActions.markResolved', 'Mark as resolved')
+ case 'keep_file':
+ return t('sourceControl.conflictActions.keepFile', 'Keep file')
+ case 'keep_deletion':
+ return t('sourceControl.conflictActions.keepDeletion', 'Keep deletion')
+ }
+}
diff --git a/mobile/src/source-control/mobile-source-control-list-styles.ts b/mobile/src/source-control/mobile-source-control-list-styles.ts
index 1e48f48a9a8..6d86bf99811 100644
--- a/mobile/src/source-control/mobile-source-control-list-styles.ts
+++ b/mobile/src/source-control/mobile-source-control-list-styles.ts
@@ -108,6 +108,25 @@ export const listStyles = themedStyleSheet(() => ({
alignItems: 'center',
justifyContent: 'center'
},
+ conflictActions: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: spacing.xs,
+ marginTop: spacing.xs
+ },
+ conflictActionButton: {
+ minHeight: 30,
+ paddingHorizontal: spacing.sm,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ conflictActionText: {
+ color: colors.textPrimary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
iconButtonPressed: {
backgroundColor: colors.bgRaised
},
diff --git a/mobile/src/source-control/mobile-source-control-screen-state.ts b/mobile/src/source-control/mobile-source-control-screen-state.ts
index 7cb1c99d19d..1596b44e6ee 100644
--- a/mobile/src/source-control/mobile-source-control-screen-state.ts
+++ b/mobile/src/source-control/mobile-source-control-screen-state.ts
@@ -21,8 +21,10 @@ import type {
} from './git-compare-reply-schema'
import type { MobileGitBranchCompareSummary } from './mobile-branch-compare'
import type { MobileGitStatusHostPayload } from './git-status-reply-schema'
+import type { ConflictResolution } from '../../../src/shared/source-control-entry-actions'
import {
canOpenMobileGitStatusEntry,
+ getMobileConflictResolutions,
isMobileGitDiscardableEntry,
isMobileGitStageableEntry,
isMobileGitUnstageableEntry,
@@ -57,7 +59,9 @@ export type MobileGitStatusEntryView = MobileGitStatusEntry & {
canOpen: boolean
canStage: boolean
canUnstage: boolean
+ conflictResolutions: ConflictResolution[]
discardActionId: string
+ resolveActionId: string
stageActionId: string
unstageActionId: string
}
@@ -72,7 +76,9 @@ export function buildMobileGitStatusEntryViews(
canOpen: canOpenMobileGitStatusEntry(entry),
canStage: isMobileGitStageableEntry(entry),
canUnstage: isMobileGitUnstageableEntry(entry),
+ conflictResolutions: getMobileConflictResolutions(entry),
discardActionId: `discard:${entry.path}`,
+ resolveActionId: `resolve:${entry.path}`,
stageActionId: `stage:${entry.path}`,
unstageActionId: `unstage:${entry.path}`
}))
diff --git a/mobile/src/source-control/mobile-source-control-styles.ts b/mobile/src/source-control/mobile-source-control-styles.ts
index 116953d1e32..abcad8dacc1 100644
--- a/mobile/src/source-control/mobile-source-control-styles.ts
+++ b/mobile/src/source-control/mobile-source-control-styles.ts
@@ -128,6 +128,21 @@ const baseStyles = themedStyleSheet(() => ({
justifyContent: 'center',
flexShrink: 0
},
+ completeMergeButton: {
+ minHeight: 32,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xs,
+ borderRadius: radii.button,
+ backgroundColor: colors.textPrimary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0
+ },
+ completeMergeText: {
+ color: colors.bgBase,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ },
abortPressed: {
opacity: 0.75
},
diff --git a/mobile/src/source-control/source-control-status-retry.test.tsx b/mobile/src/source-control/source-control-status-retry.test.tsx
index c0031847fa5..d341c77f7d8 100644
--- a/mobile/src/source-control/source-control-status-retry.test.tsx
+++ b/mobile/src/source-control/source-control-status-retry.test.tsx
@@ -60,6 +60,8 @@ vi.mock('./use-mobile-source-control-state', () => ({
stagedCount: 0,
branchEntries: [],
abortConflictOperation: () => Promise.resolve(),
+ resolveConflict: () => Promise.resolve(),
+ completeMerge: () => Promise.resolve(),
primaryActionArgs: {
status: null,
hasUnresolvedConflicts: false,
diff --git a/mobile/src/source-control/use-mobile-conflict-runners.test.ts b/mobile/src/source-control/use-mobile-conflict-runners.test.ts
new file mode 100644
index 00000000000..8ae59493276
--- /dev/null
+++ b/mobile/src/source-control/use-mobile-conflict-runners.test.ts
@@ -0,0 +1,80 @@
+import { createElement } from 'react'
+import { act, create } from 'react-test-renderer'
+import { describe, expect, it, vi } from 'vitest'
+import { useMobileConflictRunners } from './use-mobile-conflict-runners'
+import { mobileConflictNeedsNewerHostMessage } from './mobile-source-control-conflict-actions'
+
+type Runners = ReturnType
+
+// Records what the shared workflow would show: the error a step throws, or none.
+async function run(
+ sendGitRequest: (method: string, params?: Record) => Promise,
+ act_: (runners: Runners) => Promise
+): Promise {
+ let shown: string | null = null
+ let runners: Runners | null = null
+ function Harness(): null {
+ runners = useMobileConflictRunners({
+ sendGitRequest,
+ runGitAction: async () => true,
+ runGitWorkflow: async (_id, step) => {
+ try {
+ await step()
+ return true
+ } catch (err) {
+ shown = err instanceof Error ? err.message : String(err)
+ return false
+ }
+ }
+ })
+ return null
+ }
+ await act(async () => {
+ create(createElement(Harness))
+ })
+ await act(async () => {
+ if (runners) {
+ await act_(runners)
+ }
+ })
+ return shown
+}
+
+const goneRow = { path: 'gone.txt', resolveActionId: 'resolve:gone.txt' }
+
+describe('useMobileConflictRunners', () => {
+ it('sends the chosen resolution for the row to the host', async () => {
+ const sendGitRequest = vi.fn(async () => ({ ok: true }))
+ const shown = await run(sendGitRequest, (r) => r.resolveConflict(goneRow, 'keep_deletion'))
+ expect(shown).toBeNull()
+ expect(sendGitRequest).toHaveBeenCalledWith('git.resolveConflict', {
+ filePath: 'gone.txt',
+ resolution: 'keep_deletion'
+ })
+ })
+
+ it('shows Git’s refusal when the host reports Complete merge failed in its reply', async () => {
+ const sendGitRequest = vi.fn(async () => ({
+ success: false,
+ error: 'Committing is not possible because you have unmerged files.'
+ }))
+ const shown = await run(sendGitRequest, (r) => r.completeMerge())
+ expect(shown).toBe('Committing is not possible because you have unmerged files.')
+ })
+
+ it('asks for a desktop update when an older host refuses the new methods', async () => {
+ const refusal = Object.assign(
+ new Error('Method git.completeMerge is not available to mobile clients'),
+ {
+ code: 'forbidden'
+ }
+ )
+ const shown = await run(
+ vi.fn(async () => {
+ throw refusal
+ }),
+ (r) => r.completeMerge()
+ )
+ expect(shown).toBe(mobileConflictNeedsNewerHostMessage())
+ })
+})
diff --git a/mobile/src/source-control/use-mobile-conflict-runners.ts b/mobile/src/source-control/use-mobile-conflict-runners.ts
new file mode 100644
index 00000000000..80d9fc1b904
--- /dev/null
+++ b/mobile/src/source-control/use-mobile-conflict-runners.ts
@@ -0,0 +1,94 @@
+import { useCallback } from 'react'
+import type { ConflictResolution } from '../../../src/shared/source-control-entry-actions'
+import { isMobileGitUnavailable } from './mobile-git-status'
+import { translate } from '../i18n/mobile-i18n'
+import {
+ MOBILE_COMPLETE_MERGE_ACTION_ID,
+ mobileConflictNeedsNewerHostMessage
+} from './mobile-source-control-conflict-actions'
+import type { MobileGitStatusEntryView } from './mobile-source-control-screen-state'
+
+type SendGitRequest = (method: string, params?: Record) => Promise
+
+type Params = {
+ sendGitRequest: SendGitRequest
+ runGitAction: (actionId: string, method: string, p: Record) => Promise
+ runGitWorkflow: (actionId: string, runner: () => Promise) => Promise
+}
+
+/** Abort, resolve and complete a paused merge through the host ops desktop uses. */
+export function useMobileConflictRunners({ sendGitRequest, runGitAction, runGitWorkflow }: Params) {
+ // Hosts older than these ops refuse them; say what to do instead of echoing the raw refusal.
+ const sendConflictRequest = useCallback(
+ async (method: string, params?: Record): Promise => {
+ try {
+ return await sendGitRequest(method, params)
+ } catch (err) {
+ const code =
+ err instanceof Error && 'code' in err && typeof err.code === 'string'
+ ? err.code
+ : undefined
+ if (isMobileGitUnavailable(code, err instanceof Error ? err.message : undefined)) {
+ throw new Error(mobileConflictNeedsNewerHostMessage())
+ }
+ throw err
+ }
+ },
+ [sendGitRequest]
+ )
+
+ const abortConflictOperation = useCallback(
+ async (operation: string) => {
+ const method =
+ operation === 'merge' ? 'git.abortMerge' : operation === 'rebase' ? 'git.abortRebase' : null
+ if (!method) {
+ return
+ }
+ await runGitAction(`abort-${operation}`, method, {})
+ },
+ [runGitAction]
+ )
+
+ const resolveConflict = useCallback(
+ async (
+ entry: Pick,
+ resolution: ConflictResolution
+ ) => {
+ await runGitWorkflow(entry.resolveActionId, async () => {
+ await sendConflictRequest('git.resolveConflict', {
+ filePath: entry.path,
+ resolution
+ })
+ })
+ },
+ [runGitWorkflow, sendConflictRequest]
+ )
+
+ const completeMerge = useCallback(async () => {
+ await runGitWorkflow(MOBILE_COMPLETE_MERGE_ACTION_ID, async () => {
+ // completeMerge reports Git's refusal in-band, like git.commit.
+ const failure = readCompleteMergeFailure(await sendConflictRequest('git.completeMerge'))
+ if (failure) {
+ throw new Error(failure)
+ }
+ })
+ }, [runGitWorkflow, sendConflictRequest])
+
+ return { abortConflictOperation, resolveConflict, completeMerge }
+}
+
+function readCompleteMergeFailure(result: unknown): string | null {
+ const fallback = translate(
+ 'sourceControl.conflictActions.completeMergeFailed',
+ 'Complete merge failed'
+ )
+ if (typeof result !== 'object' || result === null) {
+ return fallback
+ }
+ if ('success' in result && result.success === true) {
+ return null
+ }
+ return 'error' in result && typeof result.error === 'string' && result.error
+ ? result.error
+ : fallback
+}
diff --git a/mobile/src/source-control/use-mobile-source-control-runners.ts b/mobile/src/source-control/use-mobile-source-control-runners.ts
index 468a85f6164..8157d8ef57d 100644
--- a/mobile/src/source-control/use-mobile-source-control-runners.ts
+++ b/mobile/src/source-control/use-mobile-source-control-runners.ts
@@ -8,6 +8,7 @@ import { useMobileSourceControlCommitRunners } from './use-mobile-source-control
import { useMobileSourceControlActionSheetRunners } from './use-mobile-source-control-action-sheet-runners'
import { useMobileCreatePrRunner } from './use-mobile-create-pr-runner'
import { useMobileStageAllRunner } from './use-mobile-stage-all-runner'
+import { useMobileConflictRunners } from './use-mobile-conflict-runners'
import type { RuntimeGitLocalBranches } from '../../../src/shared/runtime-types'
import type { MobileGitStatusResult } from './mobile-git-status'
import type { LoadStatusOptions } from './mobile-source-control-screen-state'
@@ -289,17 +290,7 @@ export function useMobileSourceControlRunners(params: Params) {
setShowActionSheet
})
- const abortConflictOperation = useCallback(
- async (operation: string) => {
- const method =
- operation === 'merge' ? 'git.abortMerge' : operation === 'rebase' ? 'git.abortRebase' : null
- if (!method) {
- return
- }
- await runGitAction(`abort-${operation}`, method, {})
- },
- [runGitAction]
- )
+ const conflictRunners = useMobileConflictRunners({ sendGitRequest, runGitAction, runGitWorkflow })
return {
runGitAction,
@@ -312,7 +303,7 @@ export function useMobileSourceControlRunners(params: Params) {
openBranchPicker,
openHistory,
checkoutBranch,
- abortConflictOperation,
+ ...conflictRunners,
...actionSheetRunners
}
}
diff --git a/src/main/git/source-control/complete-merge-real-git.test.ts b/src/main/git/source-control/complete-merge-real-git.test.ts
index 01de1ba8eb6..46782fe1dad 100644
--- a/src/main/git/source-control/complete-merge-real-git.test.ts
+++ b/src/main/git/source-control/complete-merge-real-git.test.ts
@@ -1,13 +1,13 @@
import { execFileSync } from 'node:child_process'
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { completeMerge } from './commit-changes'
import { detectConflictOperation } from './git-conflict-operation'
-import { stageFile } from './staging'
+import { resolveConflict } from './staging'
-describe('delete/modify merge resolved through stage and completeMerge', () => {
+describe('delete/modify merge resolved through resolveConflict and completeMerge', () => {
const tempPaths: string[] = []
afterEach(() => {
@@ -43,10 +43,27 @@ describe('delete/modify merge resolved through stage and completeMerge', () => {
return { repoPath, git }
}
+ function createBothModifiedConflict(): ReturnType {
+ const repo = createDeleteModifyConflict()
+ const { repoPath, git } = repo
+ git('merge', '--abort')
+ writeFileSync(join(repoPath, 'both.txt'), 'base\n')
+ git('add', 'both.txt')
+ git('commit', '--quiet', '-m', 'add both')
+ git('checkout', '--quiet', '-b', 'other')
+ writeFileSync(join(repoPath, 'both.txt'), 'other\n')
+ git('commit', '--quiet', '-am', 'other edit')
+ git('checkout', '--quiet', 'main')
+ writeFileSync(join(repoPath, 'both.txt'), 'main\n')
+ git('commit', '--quiet', '-am', 'main edit')
+ expect(() => git('merge', 'other')).toThrow()
+ return repo
+ }
+
it('keeps the incoming file and concludes the merge with Git’s message', async () => {
const { repoPath, git } = createDeleteModifyConflict()
- await stageFile(repoPath, 'gone.txt')
+ await resolveConflict(repoPath, 'gone.txt', 'keep_file')
await expect(completeMerge(repoPath)).resolves.toEqual({ success: true })
expect(git('log', '-1', '--format=%P').trim().split(' ')).toHaveLength(2)
@@ -55,18 +72,68 @@ describe('delete/modify merge resolved through stage and completeMerge', () => {
await expect(detectConflictOperation(repoPath)).resolves.toBe('unknown')
})
- it('keeps the deletion by staging the removed worktree file', async () => {
+ it('keeps the deletion and removes the incoming copy Git left in the worktree', async () => {
const { repoPath, git } = createDeleteModifyConflict()
- rmSync(join(repoPath, 'gone.txt'))
- await stageFile(repoPath, 'gone.txt')
+ await resolveConflict(repoPath, 'gone.txt', 'keep_deletion')
await expect(completeMerge(repoPath)).resolves.toEqual({ success: true })
expect(git('ls-tree', '--name-only', 'HEAD')).toBe('')
+ expect(existsSync(join(repoPath, 'gone.txt'))).toBe(false)
expect(git('log', '-1', '--format=%P').trim().split(' ')).toHaveLength(2)
await expect(detectConflictOperation(repoPath)).resolves.toBe('unknown')
})
+ it('refuses to resolve a path someone already resolved, keeping their staged work', async () => {
+ const { repoPath, git } = createDeleteModifyConflict()
+ writeFileSync(join(repoPath, 'gone.txt'), 'hand merged\n')
+ git('add', 'gone.txt')
+
+ await expect(resolveConflict(repoPath, 'gone.txt', 'keep_deletion')).rejects.toThrow(
+ 'no longer in conflict'
+ )
+ expect(git('show', ':gone.txt')).toBe('hand merged\n')
+ expect(existsSync(join(repoPath, 'gone.txt'))).toBe(true)
+ })
+
+ it('refuses a directory path even when a conflict sits inside it', async () => {
+ const { repoPath } = createDeleteModifyConflict()
+
+ await expect(resolveConflict(repoPath, '.', 'mark_resolved')).rejects.toThrow(
+ 'no longer in conflict'
+ )
+ })
+
+ it('refuses to keep a file that is gone from disk instead of staging its deletion', async () => {
+ const { repoPath, git } = createDeleteModifyConflict()
+ rmSync(join(repoPath, 'gone.txt'))
+
+ await expect(resolveConflict(repoPath, 'gone.txt', 'keep_file')).rejects.toThrow(
+ 'has changed since this list loaded'
+ )
+ expect(git('status', '--porcelain')).toBe('DU gone.txt\n')
+ })
+
+ it('refuses to delete a both-modified file, keeping the edits in progress', async () => {
+ const { repoPath, git } = createBothModifiedConflict()
+ writeFileSync(join(repoPath, 'both.txt'), 'half merged\n')
+
+ await expect(resolveConflict(repoPath, 'both.txt', 'keep_deletion')).rejects.toThrow(
+ 'has changed since this list loaded'
+ )
+ expect(git('status', '--porcelain', '--', 'both.txt')).toBe('UU both.txt\n')
+ })
+
+ it('refuses to mark a both-modified file resolved once it is gone from disk', async () => {
+ const { repoPath, git } = createBothModifiedConflict()
+ rmSync(join(repoPath, 'both.txt'))
+
+ await expect(resolveConflict(repoPath, 'both.txt', 'mark_resolved')).rejects.toThrow(
+ 'has changed since this list loaded'
+ )
+ expect(git('status', '--porcelain', '--', 'both.txt')).toBe('UU both.txt\n')
+ })
+
it('refuses without a merge in progress instead of committing staged changes', async () => {
const { repoPath, git } = createDeleteModifyConflict()
git('merge', '--abort')
diff --git a/src/main/git/source-control/git-pathspec.test.ts b/src/main/git/source-control/git-pathspec.test.ts
index 131e0debaad..c80ebb3ee7a 100644
--- a/src/main/git/source-control/git-pathspec.test.ts
+++ b/src/main/git/source-control/git-pathspec.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { literalPathspec } from './git-pathspec'
+import { gitReportedPath, literalPathspec } from './git-pathspec'
describe('literalPathspec', () => {
afterEach(() => {
@@ -18,3 +18,19 @@ describe('literalPathspec', () => {
expect(literalPathspec('src\\index.ts', 'C:\\repo', {})).toBe(':(literal)src\\index.ts')
})
})
+
+describe('gitReportedPath', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('matches Git status output for a native Windows path', () => {
+ vi.stubGlobal('process', { ...process, platform: 'win32' })
+ expect(gitReportedPath('src\\app.ts', 'C:\\repo', {})).toBe('src/app.ts')
+ })
+
+ it('keeps a POSIX filename backslash, which Git prints literally', () => {
+ vi.stubGlobal('process', { ...process, platform: 'linux' })
+ expect(gitReportedPath('dir\\file.txt', '/repo', {})).toBe('dir\\file.txt')
+ })
+})
diff --git a/src/main/git/source-control/git-pathspec.ts b/src/main/git/source-control/git-pathspec.ts
index c0604e16452..2c7654e786e 100644
--- a/src/main/git/source-control/git-pathspec.ts
+++ b/src/main/git/source-control/git-pathspec.ts
@@ -26,6 +26,17 @@ export function literalPathspec(
return `:(literal)${runtimePath}`
}
+/** The path as Git prints it in status output: '/' wherever Windows or WSL treats '\\' as a separator. */
+export function gitReportedPath(
+ filePath: string,
+ worktreePath: string,
+ options: GitRuntimeOptions
+): string {
+ const backslashIsSeparator =
+ process.platform === 'win32' || Boolean(options.wslDistro || wslDistroForCommand(worktreePath))
+ return backslashIsSeparator ? filePath.replace(/\\/g, '/') : filePath
+}
+
/** Measure the resolved line: WSL login wrappers quote and repeat pathspecs, multiplying raw argv length. */
function finishedCommandLineLength(
args: readonly string[],
diff --git a/src/main/git/source-control/staging.ts b/src/main/git/source-control/staging.ts
index 88bb1b58087..d67dbc3edb7 100644
--- a/src/main/git/source-control/staging.ts
+++ b/src/main/git/source-control/staging.ts
@@ -2,8 +2,13 @@ import type { GitRuntimeOptions } from '../git-runtime-options'
import { gitOptionsForWorktree } from '../git-runtime-options'
import { gitExecFileAsync } from '../runner'
import { invalidateGitReadCaches } from './git-read-cache-invalidation'
-import { literalPathspec } from './git-pathspec'
+import { gitReportedPath, literalPathspec } from './git-pathspec'
import { encodeGitPathspecs } from '../../../shared/git-pathspec-stdin'
+import {
+ getConflictResolutions,
+ type ConflictResolution
+} from '../../../shared/source-control-entry-actions'
+import { parseUnmergedEntry } from '../../../shared/git-status-conflict-entries'
import {
stageGitWorktreeScope,
type GitStageWorktreeScope,
@@ -11,6 +16,7 @@ import {
} from '../../../shared/git-stage-worktree-scope'
import { findExistingWorktreeSymlinkPaths } from '../worktree-symlink-detection'
import { redoWriteForDriveCheckoutSuspects } from './drive-checkout-suspect-writes'
+import { runWithGitWorktreeOperationLock } from '../../../shared/git-worktree-operation-lock'
const STAGE_WRITE = ['add', '--update'] as const
@@ -31,6 +37,54 @@ export async function stageFile(
}
}
+/** Git's own resolution: add keeps the worktree file; rm records the deletion and removes any leftover copy. */
+export function resolveConflict(
+ worktreePath: string,
+ filePath: string,
+ resolution: ConflictResolution,
+ options: GitRuntimeOptions = {}
+): Promise {
+ // Two clients resolving one path must each re-check after the other's change lands.
+ return runWithGitWorktreeOperationLock(worktreePath, undefined, async () => {
+ const pathspec = literalPathspec(filePath, worktreePath, options)
+ // Re-read the path: a stale row (phone poll, another client) must not override what is there now.
+ const { stdout } = await gitExecFileAsync(
+ ['status', '--porcelain=v2', '-z', '--untracked-files=no', '--', pathspec],
+ gitOptionsForWorktree(worktreePath, options)
+ )
+ // Exact path only: a directory pathspec would match its conflicts and then stage the whole directory.
+ const reportedPath = gitReportedPath(filePath, worktreePath, options)
+ const record = stdout
+ .split('\0')
+ .find((line) => line.startsWith('u ') && line.split(' ').slice(10).join(' ') === reportedPath)
+ const entry = record ? await parseUnmergedEntry(worktreePath, record) : null
+ if (!entry) {
+ throw new Error(`${filePath} is no longer in conflict. Refresh to see its current state.`)
+ }
+ // The UI's own rule on the live conflict (no git rm for a both-modified file), plus a present file to keep:
+ // the shared status ignores worktree presence for both-modified/added, where git add would stage a deletion.
+ const keepsMissingFile = resolution !== 'keep_deletion' && record?.split(' ')[6] === '000000'
+ if (!getConflictResolutions(entry).includes(resolution) || keepsMissingFile) {
+ throw new Error(
+ `${filePath} has changed since this list loaded. Refresh to see its current state.`
+ )
+ }
+ if (resolution !== 'keep_deletion') {
+ await stageFile(worktreePath, filePath, options)
+ return
+ }
+ invalidateGitReadCaches()
+ try {
+ await gitExecFileAsync(
+ ['rm', '--force', '--quiet', '--', pathspec],
+ gitOptionsForWorktree(worktreePath, options)
+ )
+ } finally {
+ invalidateGitReadCaches()
+ }
+ })
+}
+
export async function unstageFile(
worktreePath: string,
filePath: string,
diff --git a/src/main/git/status.ts b/src/main/git/status.ts
index 9e30dc31c1d..922782186d9 100644
--- a/src/main/git/status.ts
+++ b/src/main/git/status.ts
@@ -33,6 +33,7 @@ export { getCommitDiff } from './source-control/commit-diff'
export {
bulkStageFiles,
bulkUnstageFiles,
+ resolveConflict,
stageFile,
stageWorktreeChanges,
unstageFile
diff --git a/src/main/ipc/filesystem/filesystem-git-index-handlers.ts b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts
index 5a1a66028b7..9183925b1b1 100644
--- a/src/main/ipc/filesystem/filesystem-git-index-handlers.ts
+++ b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron'
import {
+ resolveConflict,
stageFile,
unstageFile,
discardChanges,
@@ -19,6 +20,10 @@ import type { FilesystemHandlerContext } from './filesystem-handler-context'
import { parseGitStageWorktreeScope } from '../../../shared/git-stage-worktree-scope'
import { requireReachableGitRoute } from '../../providers/execution-host-provider-dispatch'
import { getConnectionExecutionHostId } from '../../../shared/execution-host'
+import {
+ CONFLICT_RESOLUTIONS,
+ type ConflictResolution
+} from '../../../shared/source-control-entry-actions'
export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerContext): void {
const { store } = context
@@ -40,6 +45,35 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon
}
)
+ ipcMain.handle(
+ 'git:resolveConflict',
+ async (
+ _event,
+ args: {
+ worktreePath: string
+ filePath: string
+ resolution: ConflictResolution
+ connectionId?: string
+ }
+ ): Promise => {
+ if (!CONFLICT_RESOLUTIONS.includes(args.resolution)) {
+ throw new Error('Unknown conflict resolution')
+ }
+ requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
+ const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
+ const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
+ const gitOptions = getLocalGitOptionsForRegisteredWorktree(
+ store,
+ args.worktreePath,
+ worktreePath
+ )
+ await resolveConflict(worktreePath, filePath, args.resolution, {
+ ...gitOptions,
+ admissionTier: 'interactive'
+ })
+ }
+ )
+
ipcMain.handle(
'git:unstage',
async (
diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts
index 1950cfab64e..0c9c7ced9ca 100644
--- a/src/main/providers/git-provider-contract.ts
+++ b/src/main/providers/git-provider-contract.ts
@@ -15,6 +15,7 @@ import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
+import type { ConflictResolution } from '../../shared/source-control-entry-actions'
import type { GitProviderStatusOptions } from './git-provider-status-options'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
@@ -39,6 +40,11 @@ export type IGitProvider = {
compareAgainstHead?: boolean
): Promise
stageFile(worktreePath: string, filePath: string): Promise
+ resolveConflict(
+ worktreePath: string,
+ filePath: string,
+ resolution: ConflictResolution
+ ): Promise
unstageFile(worktreePath: string, filePath: string): Promise
bulkStageFiles(
worktreePath: string,
diff --git a/src/main/providers/local-git-provider.ts b/src/main/providers/local-git-provider.ts
index fbe72623faf..81eb0c23a4c 100644
--- a/src/main/providers/local-git-provider.ts
+++ b/src/main/providers/local-git-provider.ts
@@ -23,6 +23,7 @@ import {
getStagedCommitContext,
getStatus,
getSubmoduleStatus,
+ resolveConflict,
stageFile,
stageWorktreeChanges,
unstageFile
@@ -78,6 +79,8 @@ export function createLocalGitProvider(options: LocalGitProviderOptions = {}): L
getDiff: (worktreePath, filePath, staged, compareAgainstHead) =>
getDiff(worktreePath, filePath, staged, compareAgainstHead, interactive),
stageFile: (worktreePath, filePath) => stageFile(worktreePath, filePath, interactive),
+ resolveConflict: (worktreePath, filePath, resolution) =>
+ resolveConflict(worktreePath, filePath, resolution, interactive),
unstageFile: (worktreePath, filePath) => unstageFile(worktreePath, filePath, interactive),
bulkStageFiles: async (worktreePath, filePaths, scope) => {
if (scope) {
diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts
index 4c12fc77a8b..900a15a5d5a 100644
--- a/src/main/runtime/orca-runtime-git.ts
+++ b/src/main/runtime/orca-runtime-git.ts
@@ -38,6 +38,7 @@ export class RuntimeGitCommands {
readonly cancelRuntimeGeneratePullRequestFields: RuntimeGitGenerationCommands['cancelRuntimeGeneratePullRequestFields']
readonly discoverRuntimeCommitMessageModels: RuntimeGitGenerationCommands['discoverRuntimeCommitMessageModels']
readonly stageRuntimeGitPath: RuntimeGitStagingCommands['stageRuntimeGitPath']
+ readonly resolveRuntimeGitConflict: RuntimeGitStagingCommands['resolveRuntimeGitConflict']
readonly unstageRuntimeGitPath: RuntimeGitStagingCommands['unstageRuntimeGitPath']
readonly bulkStageRuntimeGitPaths: RuntimeGitStagingCommands['bulkStageRuntimeGitPaths']
readonly bulkUnstageRuntimeGitPaths: RuntimeGitStagingCommands['bulkUnstageRuntimeGitPaths']
@@ -87,6 +88,7 @@ export class RuntimeGitCommands {
this.discoverRuntimeCommitMessageModels =
generation.discoverRuntimeCommitMessageModels.bind(generation)
this.stageRuntimeGitPath = staging.stageRuntimeGitPath.bind(staging)
+ this.resolveRuntimeGitConflict = staging.resolveRuntimeGitConflict.bind(staging)
this.unstageRuntimeGitPath = staging.unstageRuntimeGitPath.bind(staging)
this.bulkStageRuntimeGitPaths = staging.bulkStageRuntimeGitPaths.bind(staging)
this.bulkUnstageRuntimeGitPaths = staging.bulkUnstageRuntimeGitPaths.bind(staging)
diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts
index 64e73dae0e8..f2c8732b6ec 100644
--- a/src/main/runtime/rpc/methods/git.ts
+++ b/src/main/runtime/rpc/methods/git.ts
@@ -10,6 +10,7 @@ import {
GitCommit,
GitCommitCompare,
GitFilePath,
+ GitResolveConflict,
GitForkSync,
GitHistory,
GitPush,
@@ -225,6 +226,13 @@ export const GIT_METHODS = [
handler: async (params, { runtime }) =>
runtime.bulkStageRuntimeGitPaths(params.worktree, params.filePaths, params.scope)
}),
+ defineMethod({
+ name: 'git.resolveConflict',
+ permission: 'workspace',
+ params: GitResolveConflict,
+ handler: async (params, { runtime }) =>
+ runtime.resolveRuntimeGitConflict(params.worktree, params.filePath, params.resolution)
+ }),
defineMethod({
name: 'git.unstage',
permission: 'workspace',
diff --git a/src/main/runtime/runtime-git-command-surface.ts b/src/main/runtime/runtime-git-command-surface.ts
index 79328e7d467..22f298f10ff 100644
--- a/src/main/runtime/runtime-git-command-surface.ts
+++ b/src/main/runtime/runtime-git-command-surface.ts
@@ -31,6 +31,7 @@ type RuntimeGitCommandName =
| 'generateRuntimePullRequestFields'
| 'cancelRuntimeGeneratePullRequestFields'
| 'stageRuntimeGitPath'
+ | 'resolveRuntimeGitConflict'
| 'unstageRuntimeGitPath'
| 'bulkStageRuntimeGitPaths'
| 'bulkUnstageRuntimeGitPaths'
@@ -77,6 +78,7 @@ export function installRuntimeGitCommandSurface(
cancelRuntimeGeneratePullRequestFields:
commands.cancelRuntimeGeneratePullRequestFields.bind(commands),
stageRuntimeGitPath: commands.stageRuntimeGitPath.bind(commands),
+ resolveRuntimeGitConflict: commands.resolveRuntimeGitConflict.bind(commands),
unstageRuntimeGitPath: commands.unstageRuntimeGitPath.bind(commands),
bulkStageRuntimeGitPaths: commands.bulkStageRuntimeGitPaths.bind(commands),
bulkUnstageRuntimeGitPaths: commands.bulkUnstageRuntimeGitPaths.bind(commands),
diff --git a/src/main/runtime/runtime-git-staging-commands.ts b/src/main/runtime/runtime-git-staging-commands.ts
index 025952db9b1..87ffd5c549d 100644
--- a/src/main/runtime/runtime-git-staging-commands.ts
+++ b/src/main/runtime/runtime-git-staging-commands.ts
@@ -7,6 +7,7 @@ import {
requireRuntimeGitProvider,
type RuntimeGitCommandHost
} from './runtime-git-command-target'
+import type { ConflictResolution } from '../../shared/source-control-entry-actions'
export class RuntimeGitStagingCommands {
constructor(private readonly host: RuntimeGitCommandHost) {}
@@ -18,6 +19,21 @@ export class RuntimeGitStagingCommands {
return { ok: true }
}
+ async resolveRuntimeGitConflict(
+ worktreeSelector: string,
+ filePath: string,
+ resolution: ConflictResolution
+ ): Promise<{ ok: true }> {
+ const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
+ const relativePath = normalizeRuntimeGitRelativePath(filePath)
+ await requireRuntimeGitProvider(target).resolveConflict(
+ target.worktree.path,
+ relativePath,
+ resolution
+ )
+ return { ok: true }
+ }
+
async unstageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts
index 70413073f4d..1cc713b2032 100644
--- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts
+++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts
@@ -66,6 +66,7 @@ export const EXECUTION_HOST_METHODS = [
'git.commit',
'git.commitCompare',
'git.commitDiff',
+ 'git.completeMerge',
'git.diff',
'git.discard',
'git.discoverCommitMessageModels',
@@ -79,6 +80,7 @@ export const EXECUTION_HOST_METHODS = [
'git.pull',
'git.push',
'git.rebaseFromBase',
+ 'git.resolveConflict',
'git.stage',
'git.status',
'git.unstage',
diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts
index f6321855642..b546821f85a 100644
--- a/src/preload/api/git-bridge.ts
+++ b/src/preload/api/git-bridge.ts
@@ -1,6 +1,7 @@
import { ipcRenderer } from 'electron'
import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync'
import type { GitStagingArea, GitUpstreamStatus } from '../../shared/git-status-types'
+import type { ConflictResolution } from '../../shared/source-control-entry-actions'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitStageWorktreeScope } from '../../shared/git-stage-worktree-scope'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
@@ -176,6 +177,12 @@ export const gitApi = {
}): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args),
stage: (args: { worktreePath: string; filePath: string; connectionId?: string }): Promise =>
ipcRenderer.invoke('git:stage', args),
+ resolveConflict: (args: {
+ worktreePath: string
+ filePath: string
+ resolution: ConflictResolution
+ connectionId?: string
+ }): Promise => ipcRenderer.invoke('git:resolveConflict', args),
bulkStage: (args: {
worktreePath: string
filePaths: string[]
diff --git a/src/preload/api/git-operation-api.ts b/src/preload/api/git-operation-api.ts
index 26c7cac46b9..d77293fed43 100644
--- a/src/preload/api/git-operation-api.ts
+++ b/src/preload/api/git-operation-api.ts
@@ -2,6 +2,7 @@ import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../share
import type { TuiAgent } from '../../shared/tui-agent'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitStageWorktreeScope } from '../../shared/git-stage-worktree-scope'
+import type { ConflictResolution } from '../../shared/source-control-entry-actions'
import type { HostedReviewProvider } from '../../shared/hosted-review'
import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai'
import type { SourceControlAiSettings } from '../../shared/source-control-ai-types'
@@ -102,6 +103,12 @@ export type GitOperationApi = {
connectionId?: string
}) => Promise
stage: (args: { worktreePath: string; filePath: string; connectionId?: string }) => Promise
+ resolveConflict: (args: {
+ worktreePath: string
+ filePath: string
+ resolution: ConflictResolution
+ connectionId?: string
+ }) => Promise
bulkStage: (args: {
worktreePath: string
filePaths: string[]
diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts
index a1e059bad21..2a67532bd17 100644
--- a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts
+++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts
@@ -10,13 +10,13 @@ import {
requestEditorSaveQuiesce
} from '@/components/editor/editor-autosave'
import { getConnectionId } from '@/lib/connection-context'
-import { basename, joinPath } from '@/lib/path'
+import { basename } from '@/lib/path'
import { useAppStore } from '@/store'
-import { deleteRuntimePath } from '@/runtime/runtime-file-mutation-client'
import {
bulkDiscardRuntimeGitPaths,
bulkUnstageRuntimeGitPaths,
discardRuntimeGitPath,
+ resolveRuntimeGitConflict,
stageRuntimeGitPath,
unstageRuntimeGitPath
} from '@/runtime/runtime-git-working-tree-client'
@@ -120,36 +120,28 @@ export function useSourceControlEntryMutations({
runtimeEnvironmentId:
activeRepoTarget?.kind === 'environment' ? activeRepoTarget.environmentId : null
}
- const resolve: (context: RuntimeGitContext, filePath: string) => Promise =
- resolution !== 'keep_deletion'
- ? async (context, filePath) => {
- // Same view match as autosave holds: every workspace's view of this physical file, not another host's.
- const current = useAppStore.getState()
- const keys = pathMutationHoldKeys(current, editorTarget)
- const dirtyFiles = current.openFiles.filter(
- (file) => file.isDirty && keys.has(editorFileHoldKey(current, file))
- )
- // Stage what the user sees: an unsaved resolution must reach disk before git add reads it.
- await Promise.all(
- dirtyFiles.map((file) => requestEditorFileSave({ fileId: file.id }))
- )
- await stageRuntimeGitPath(context, filePath)
- }
- : async (context, filePath) => {
- // Hold autosave so an open tab cannot recreate the file after it is removed.
- const releaseAutoSave = await requestEditorSaveQuiesce(editorTarget)
- try {
- // Delete even if status said absent so a since-restored file is never staged; Git history keeps it.
- await deleteRuntimePath(context, joinPath(worktreePath, filePath), false, {
- permanent: true
- })
- notifyEditorExternalFileChange(editorTarget)
- // With the worktree file gone, git add stages the removal and resolves the conflict.
- await stageRuntimeGitPath(context, filePath)
- } finally {
- releaseAutoSave()
- }
- }
+ const resolve = async (context: RuntimeGitContext, filePath: string): Promise => {
+ if (resolution !== 'keep_deletion') {
+ // Same view match as autosave holds: every workspace's view of this physical file, not another host's.
+ const current = useAppStore.getState()
+ const keys = pathMutationHoldKeys(current, editorTarget)
+ const dirtyFiles = current.openFiles.filter(
+ (file) => file.isDirty && keys.has(editorFileHoldKey(current, file))
+ )
+ // Stage what the user sees: an unsaved resolution must reach disk before git add reads it.
+ await Promise.all(dirtyFiles.map((file) => requestEditorFileSave({ fileId: file.id })))
+ await resolveRuntimeGitConflict(context, filePath, resolution)
+ return
+ }
+ // Hold autosave so an open tab cannot recreate the file the host removes.
+ const releaseAutoSave = await requestEditorSaveQuiesce(editorTarget)
+ try {
+ await resolveRuntimeGitConflict(context, filePath, resolution)
+ notifyEditorExternalFileChange(editorTarget)
+ } finally {
+ releaseAutoSave()
+ }
+ }
resolvingConflictKeysRef.current.add(resolvingKey)
return runEntryMutation('stage', entry.path, resolve).finally(() => {
resolvingConflictKeysRef.current.delete(resolvingKey)
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 8c2491d95fc..816f86bbefa 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18965,6 +18965,9 @@
},
"runtimeGitSyncClient": {
"completeMergeNeedsNewerHost": "Completing a merge needs a newer Orca on this host. Update Orca on that host."
+ },
+ "runtimeGitWorkingTreeClient": {
+ "resolveConflictNeedsNewerHost": "Resolving conflicts needs a newer Orca on this host. Update Orca on that host."
}
},
"ssh": {
diff --git a/src/renderer/src/runtime/runtime-git-working-tree-client.ts b/src/renderer/src/runtime/runtime-git-working-tree-client.ts
index b35db00c618..820dbb4b00f 100644
--- a/src/renderer/src/runtime/runtime-git-working-tree-client.ts
+++ b/src/renderer/src/runtime/runtime-git-working-tree-client.ts
@@ -3,7 +3,10 @@ import {
type GitStageWorktreeScope
} from '../../../shared/git-stage-worktree-scope'
import { resolveLocalWorktreePath, type RuntimeGitContext } from './runtime-git-client-context'
+import type { ConflictResolution } from '../../../shared/source-control-entry-actions'
+import { translate } from '@/i18n/i18n'
import { callRuntimeRpc } from './runtime-rpc-client'
+import { RuntimeRpcCallError } from './runtime-rpc-result'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
export async function stageRuntimeGitPath(
@@ -27,6 +30,41 @@ export async function stageRuntimeGitPath(
)
}
+export async function resolveRuntimeGitConflict(
+ context: RuntimeGitContext,
+ filePath: string,
+ resolution: ConflictResolution
+): Promise {
+ const target = context.target
+ if (target.kind === 'local' || !context.worktreeId) {
+ await window.api.git.resolveConflict({
+ worktreePath: resolveLocalWorktreePath(context),
+ filePath,
+ resolution,
+ connectionId: context.connectionId
+ })
+ return
+ }
+ try {
+ await callRuntimeRpc(
+ target,
+ 'git.resolveConflict',
+ { worktree: toRuntimeWorktreeSelector(context.worktreeId), filePath, resolution },
+ { timeoutMs: 15_000 }
+ )
+ } catch (error) {
+ if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') {
+ throw new Error(
+ translate(
+ 'auto.runtime.runtimeGitWorkingTreeClient.resolveConflictNeedsNewerHost',
+ 'Resolving conflicts needs a newer Orca on this host. Update Orca on that host.'
+ )
+ )
+ }
+ throw error
+ }
+}
+
export async function bulkStageRuntimeGitPaths(
context: RuntimeGitContext,
filePaths: string[]
diff --git a/src/renderer/src/web/preload-api/web-git-api.ts b/src/renderer/src/web/preload-api/web-git-api.ts
index c7a3a7c87b2..8af99b9585b 100644
--- a/src/renderer/src/web/preload-api/web-git-api.ts
+++ b/src/renderer/src/web/preload-api/web-git-api.ts
@@ -3,7 +3,7 @@ import { callAbortableRuntimeEnvironment } from '../../runtime/abortable-runtime
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { translate } from '@/i18n/i18n'
import { callRuntimeResult } from './web-runtime-calls'
-import { mutateGitPath, mutateGitPaths } from './web-git-mutations'
+import { mutateGitPath, mutateGitPaths, resolveGitConflict } from './web-git-mutations'
import { requireGitStageWorktreeScopeReceipt } from '../../../../shared/git-stage-worktree-scope'
import { requireActiveEnvironment, updateEnvironmentFromResponse } from './web-runtime-session'
import {
@@ -255,6 +255,8 @@ export function createGitApi(): NonNullable['git']> {
}),
cancelGeneratePullRequestFields: () => Promise.resolve(),
stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath),
+ resolveConflict: async ({ worktreePath, filePath, resolution }) =>
+ resolveGitConflict(worktreePath, filePath, resolution),
bulkStage: async ({ worktreePath, filePaths, scope }) => {
if (!scope) {
return mutateGitPaths('git.bulkStage', worktreePath, filePaths)
diff --git a/src/renderer/src/web/preload-api/web-git-mutations.ts b/src/renderer/src/web/preload-api/web-git-mutations.ts
index f771e5f4e30..72f8d425044 100644
--- a/src/renderer/src/web/preload-api/web-git-mutations.ts
+++ b/src/renderer/src/web/preload-api/web-git-mutations.ts
@@ -1,4 +1,5 @@
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
+import type { ConflictResolution } from '../../../../shared/source-control-entry-actions'
import { callRuntimeResult } from './web-runtime-calls'
import {
resolveRuntimeFilePath,
@@ -25,3 +26,16 @@ export async function mutateGitPaths(
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id), filePaths })
}
+
+export async function resolveGitConflict(
+ worktreePath: string,
+ filePath: string,
+ resolution: ConflictResolution
+): Promise {
+ const file = await resolveRuntimeFilePath(filePath, worktreePath)
+ await callRuntimeResult('git.resolveConflict', {
+ worktree: toRuntimeWorktreeSelector(file.worktree.id),
+ filePath: file.relativePath,
+ resolution
+ })
+}
diff --git a/src/shared/rpc-contract/git-params.ts b/src/shared/rpc-contract/git-params.ts
index db870b42af4..00f66d3214c 100644
--- a/src/shared/rpc-contract/git-params.ts
+++ b/src/shared/rpc-contract/git-params.ts
@@ -2,6 +2,7 @@ import { normalizeSourceControlAiActionDefaults } from '../source-control-ai-act
import { z } from 'zod'
import { OptionalGitAdmissionTier } from './git-admission-tier-params'
import { OptionalTuiAgent } from './worktree-params'
+import { CONFLICT_RESOLUTIONS } from '../source-control-entry-actions'
export const WorktreeSelector = z.object({
worktree: z
@@ -46,6 +47,10 @@ export const GitFilePath = WorktreeSelector.extend({
.pipe(z.string().min(1, 'Missing file path'))
})
+export const GitResolveConflict = GitFilePath.extend({
+ resolution: z.enum(CONFLICT_RESOLUTIONS)
+})
+
export const GitDiff = GitFilePath.extend({
staged: z.boolean(),
compareAgainstHead: z.boolean().optional()
diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts
index 3f1666ed73b..360a6fda6ad 100644
--- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts
+++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts
@@ -276,6 +276,7 @@ import {
GitRebaseFromBase,
GitRemoteCommitUrl,
GitRemoteFileUrl,
+ GitResolveConflict,
GitStatusParams,
GitSubmoduleStatus,
GitTargetedRemote,
@@ -1037,6 +1038,7 @@ export const RPC_PARAMS_BY_METHOD = {
'git.rebaseFromBase': GitRebaseFromBase,
'git.remoteCommitUrl': GitRemoteCommitUrl,
'git.remoteFileUrl': GitRemoteFileUrl,
+ 'git.resolveConflict': GitResolveConflict,
'git.stage': GitFilePath,
'git.status': GitStatusParams,
'git.submoduleStatus': GitSubmoduleStatus,
diff --git a/src/shared/source-control-entry-actions.ts b/src/shared/source-control-entry-actions.ts
index 180cdf65ec3..75aa0a689bc 100644
--- a/src/shared/source-control-entry-actions.ts
+++ b/src/shared/source-control-entry-actions.ts
@@ -20,7 +20,8 @@ export function canDiscardStatusEntry(entry: GitStatusEntry): boolean {
)
}
-export type ConflictResolution = 'mark_resolved' | 'keep_file' | 'keep_deletion'
+export const CONFLICT_RESOLUTIONS = ['mark_resolved', 'keep_file', 'keep_deletion'] as const
+export type ConflictResolution = (typeof CONFLICT_RESOLUTIONS)[number]
/** Staging the worktree is Git's resolution; a missing worktree file can only resolve as a deletion. */
export function getConflictResolutions(entry: GitStatusEntry): ConflictResolution[] {