mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
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:
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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[]
|
||||
|
||||
+24
-32
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user