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[] {