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
This commit is contained in:
Kelvin Amoaba
2026-10-30 03:18:48 +00:00
committed by GitHub
parent 7679916342
commit dc99281085
40 changed files with 689 additions and 63 deletions
+11 -1
View File
@@ -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": {
@@ -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 ? (
<View style={styles.conflictRow}>
<Text style={styles.conflictText}>{conflictLabel}</Text>
{onCompleteMerge ? (
<Pressable
style={({ pressed }) => [
styles.completeMergeButton,
conflictBusy && styles.abortButtonDisabled,
pressed && !conflictBusy && styles.abortPressed
]}
disabled={conflictBusy}
onPress={onCompleteMerge}
>
<Text style={styles.completeMergeText}>
{completingMerge
? t('sourceControl.branchCard.completingMerge', 'Completing…')
: t('sourceControl.branchCard.completeMerge', 'Complete merge')}
</Text>
</Pressable>
) : null}
{conflictOperation === 'merge' || conflictOperation === 'rebase' ? (
<Pressable
style={({ pressed }) => [
@@ -196,6 +196,8 @@ function initialState(): MobileSourceControlContentState {
openHistory: noop,
checkoutBranch: unusedAsync,
abortConflictOperation: unusedAsync,
resolveConflict: unusedAsync,
completeMerge: unusedAsync,
runActionSheetCommit: unusedAsync,
runActionSheetCommitSequence: unusedAsync,
runActionSheetCommitSync: unusedAsync,
@@ -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,
@@ -34,6 +34,7 @@ export type FileListState = Pick<
| 'openingBranchPath'
| 'openFile'
| 'openBranchDiff'
| 'resolveConflict'
| 'runGitAction'
| 'setDiscardTarget'
>
@@ -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()
}
}
@@ -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 (
<Pressable
style={({ pressed }) => [
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)}
</Text>
) : null}
{!rowBusy && hasConflictActions ? (
<View style={styles.conflictActions}>
{item.conflictResolutions.map((resolution) => (
<Pressable
key={resolution}
style={({ pressed }) => [
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
}
)}
>
<Text style={styles.conflictActionText}>
{mobileConflictResolutionLabel(resolution, t)}
</Text>
</Pressable>
))}
</View>
) : null}
</View>
{rowBusy ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
@@ -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}
/>
@@ -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)
}
@@ -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')
}
}
@@ -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
},
@@ -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}`
}))
@@ -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
},
@@ -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,
@@ -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<typeof useMobileConflictRunners>
// Records what the shared workflow would show: the error a step throws, or none.
async function run(
sendGitRequest: (method: string, params?: Record<string, unknown>) => Promise<unknown>,
act_: (runners: Runners) => Promise<void>
): Promise<string | null> {
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())
})
})
@@ -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<string, unknown>) => Promise<unknown>
type Params = {
sendGitRequest: SendGitRequest
runGitAction: (actionId: string, method: string, p: Record<string, unknown>) => Promise<boolean>
runGitWorkflow: (actionId: string, runner: () => Promise<void>) => Promise<boolean>
}
/** 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<string, unknown>): Promise<unknown> => {
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<MobileGitStatusEntryView, 'path' | 'resolveActionId'>,
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
}
@@ -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
}
}
@@ -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<typeof createDeleteModifyConflict> {
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')
@@ -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')
})
})
@@ -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[],
+55 -1
View File
@@ -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<void> {
// 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,
+1
View File
@@ -33,6 +33,7 @@ export { getCommitDiff } from './source-control/commit-diff'
export {
bulkStageFiles,
bulkUnstageFiles,
resolveConflict,
stageFile,
stageWorktreeChanges,
unstageFile
@@ -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<void> => {
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 (
@@ -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<GitDiffResult>
stageFile(worktreePath: string, filePath: string): Promise<void>
resolveConflict(
worktreePath: string,
filePath: string,
resolution: ConflictResolution
): Promise<void>
unstageFile(worktreePath: string, filePath: string): Promise<void>
bulkStageFiles(
worktreePath: string,
+3
View File
@@ -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) {
+2
View File
@@ -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)
+8
View File
@@ -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',
@@ -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),
@@ -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)
@@ -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',
+7
View File
@@ -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<void> => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args),
stage: (args: { worktreePath: string; filePath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('git:stage', args),
resolveConflict: (args: {
worktreePath: string
filePath: string
resolution: ConflictResolution
connectionId?: string
}): Promise<void> => ipcRenderer.invoke('git:resolveConflict', args),
bulkStage: (args: {
worktreePath: string
filePaths: string[]
+7
View File
@@ -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<void>
stage: (args: { worktreePath: string; filePath: string; connectionId?: string }) => Promise<void>
resolveConflict: (args: {
worktreePath: string
filePath: string
resolution: ConflictResolution
connectionId?: string
}) => Promise<void>
bulkStage: (args: {
worktreePath: string
filePaths: string[]
@@ -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<void> =
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<void> => {
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)
+3
View File
@@ -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": {
@@ -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<void> {
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[]
@@ -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<Partial<PreloadApi>['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)
@@ -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<void> {
const file = await resolveRuntimeFilePath(filePath, worktreePath)
await callRuntimeResult('git.resolveConflict', {
worktree: toRuntimeWorktreeSelector(file.worktree.id),
filePath: file.relativePath,
resolution
})
}
+5
View File
@@ -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()
+2
View File
@@ -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,
+2 -1
View File
@@ -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[] {