mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat: add source control action to abort merge (#2092)
Co-authored-by: Orca <help@stably.ai> Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
co-authored by
Orca
brennanb2025
parent
a65f7b4216
commit
659fe79226
@@ -38,6 +38,7 @@ vi.mock('fs', () => ({
|
||||
}))
|
||||
|
||||
import {
|
||||
abortMerge,
|
||||
bulkStageFiles,
|
||||
bulkDiscardChanges,
|
||||
bulkUnstageFiles,
|
||||
@@ -647,6 +648,20 @@ describe('getStatus', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('abortMerge', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('runs git merge --abort in the worktree', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await abortMerge('/repo')
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['merge', '--abort'], { cwd: '/repo' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStagedCommitContext', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
|
||||
@@ -459,6 +459,10 @@ export async function detectConflictOperation(worktreePath: string): Promise<Git
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export async function abortMerge(worktreePath: string): Promise<void> {
|
||||
await gitExecFileAsync(['merge', '--abort'], { cwd: worktreePath })
|
||||
}
|
||||
|
||||
export async function resolveGitDir(worktreePath: string): Promise<string> {
|
||||
const dotGitPath = path.join(worktreePath, '.git')
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
lstatMock,
|
||||
commitChangesMock,
|
||||
getStatusMock,
|
||||
abortMergeMock,
|
||||
getDiffMock,
|
||||
getBranchCompareMock,
|
||||
getBranchDiffMock,
|
||||
@@ -46,6 +47,7 @@ const {
|
||||
lstatMock: vi.fn(),
|
||||
commitChangesMock: vi.fn(),
|
||||
getStatusMock: vi.fn(),
|
||||
abortMergeMock: vi.fn(),
|
||||
getDiffMock: vi.fn(),
|
||||
getBranchCompareMock: vi.fn(),
|
||||
getBranchDiffMock: vi.fn(),
|
||||
@@ -89,6 +91,7 @@ vi.mock('fs/promises', () => ({
|
||||
vi.mock('../git/status', () => ({
|
||||
commitChanges: commitChangesMock,
|
||||
getStatus: getStatusMock,
|
||||
abortMerge: abortMergeMock,
|
||||
getDiff: getDiffMock,
|
||||
getBranchCompare: getBranchCompareMock,
|
||||
getBranchDiff: getBranchDiffMock,
|
||||
@@ -579,6 +582,26 @@ describe('registerFilesystemHandlers', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('routes abort merge through local and SSH git providers', async () => {
|
||||
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
|
||||
abortMergeMock.mockResolvedValue(undefined)
|
||||
const sshProvider = {
|
||||
abortMerge: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(sshProvider)
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await handlers.get('git:abortMerge')!(null, { worktreePath: WORKTREE_FEATURE_PATH })
|
||||
await handlers.get('git:abortMerge')!(null, {
|
||||
worktreePath: '/remote/repo',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
|
||||
expect(abortMergeMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH)
|
||||
expect(sshProvider.abortMerge).toHaveBeenCalledWith('/remote/repo')
|
||||
})
|
||||
|
||||
it('rejects git file paths that escape the selected worktree', async () => {
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../../shared/text-search'
|
||||
import {
|
||||
getStatus,
|
||||
abortMerge,
|
||||
detectConflictOperation,
|
||||
getDiff,
|
||||
commitChanges,
|
||||
@@ -567,6 +568,21 @@ export function registerFilesystemHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'git:abortMerge',
|
||||
async (_event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
|
||||
if (args.connectionId) {
|
||||
const provider = getSshGitProvider(args.connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(`No git provider for connection "${args.connectionId}"`)
|
||||
}
|
||||
return provider.abortMerge(args.worktreePath)
|
||||
}
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
await abortMerge(worktreePath)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'git:diff',
|
||||
async (
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SshGitProvider } from './ssh-git-provider'
|
||||
|
||||
describe('SshGitProvider merge operations', () => {
|
||||
it('abortMerge sends git.abortMerge request', async () => {
|
||||
const mux = {
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
notify: vi.fn(),
|
||||
onNotification: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
isDisposed: vi.fn().mockReturnValue(false)
|
||||
}
|
||||
const provider = new SshGitProvider('conn-1', mux as never)
|
||||
|
||||
await provider.abortMerge('/home/user/repo')
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith('git.abortMerge', {
|
||||
worktreePath: '/home/user/repo'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -281,6 +281,10 @@ export class SshGitProvider implements IGitProvider {
|
||||
})) as GitConflictOperation
|
||||
}
|
||||
|
||||
async abortMerge(worktreePath: string): Promise<void> {
|
||||
await this.mux.request('git.abortMerge', { worktreePath })
|
||||
}
|
||||
|
||||
async getBranchCompare(worktreePath: string, baseRef: string): Promise<GitBranchCompareResult> {
|
||||
return (await this.mux.request('git.branchCompare', {
|
||||
worktreePath,
|
||||
|
||||
@@ -172,6 +172,7 @@ export type IGitProvider = {
|
||||
discardChanges(worktreePath: string, filePath: string): Promise<void>
|
||||
bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise<void>
|
||||
detectConflictOperation(worktreePath: string): Promise<GitConflictOperation>
|
||||
abortMerge(worktreePath: string): Promise<void>
|
||||
getBranchCompare(worktreePath: string, baseRef: string): Promise<GitBranchCompareResult>
|
||||
getCommitCompare(worktreePath: string, commitId: string): Promise<GitCommitCompareResult>
|
||||
getUpstreamStatus(worktreePath: string, pushTarget?: GitPushTarget): Promise<GitUpstreamStatus>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type * as CommitMessageTextGenerationModule from '../text-generation/comm
|
||||
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
abortMerge: vi.fn(),
|
||||
getStagedCommitContext: vi.fn(),
|
||||
generateCommitMessageFromContext: vi.fn(),
|
||||
resolveCommitMessageSettings: vi.fn(),
|
||||
@@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../git/status', async () => ({
|
||||
...(await vi.importActual<typeof GitStatusModule>('../git/status')),
|
||||
abortMerge: mocks.abortMerge,
|
||||
getStagedCommitContext: mocks.getStagedCommitContext
|
||||
}))
|
||||
|
||||
@@ -57,6 +59,7 @@ function makeCommands(worktreePath: string): RuntimeGitCommands {
|
||||
|
||||
describe('RuntimeGitCommands', () => {
|
||||
beforeEach(() => {
|
||||
mocks.abortMerge.mockReset()
|
||||
mocks.getStagedCommitContext.mockReset()
|
||||
mocks.generateCommitMessageFromContext.mockReset()
|
||||
mocks.resolveCommitMessageSettings.mockReset()
|
||||
@@ -69,6 +72,34 @@ describe('RuntimeGitCommands', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts a local merge through the resolved worktree', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
const commands = makeCommands(worktreePath)
|
||||
mocks.abortMerge.mockResolvedValue(undefined)
|
||||
|
||||
await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(mocks.abortMerge).toHaveBeenCalledWith(worktreePath)
|
||||
})
|
||||
|
||||
it('aborts a remote merge through the SSH git provider', async () => {
|
||||
const provider = { abortMerge: vi.fn().mockResolvedValue(undefined) }
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: makeWorktree('/remote/repo'),
|
||||
connectionId: 'conn-1'
|
||||
}),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
|
||||
await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(provider.abortMerge).toHaveBeenCalledWith('/remote/repo')
|
||||
expect(mocks.abortMerge).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects slash-only git mutation paths before they can target the worktree root', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-messa
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
|
||||
import { getRemoteFileUrl } from '../git/repo'
|
||||
import {
|
||||
abortMerge,
|
||||
bulkDiscardChanges,
|
||||
bulkStageFiles,
|
||||
bulkUnstageFiles,
|
||||
@@ -148,6 +149,20 @@ export class RuntimeGitCommands {
|
||||
return detectConflictOperation(target.worktree.path)
|
||||
}
|
||||
|
||||
async abortRuntimeGitMerge(worktreeSelector: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
await provider.abortMerge(target.worktree.path)
|
||||
return { ok: true }
|
||||
}
|
||||
await abortMerge(target.worktree.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async getRuntimeGitDiff(
|
||||
worktreeSelector: string,
|
||||
filePath: string,
|
||||
|
||||
@@ -2047,6 +2047,8 @@ export class OrcaRuntimeService {
|
||||
this.gitCommands.getRuntimeGitHistory.bind(this.gitCommands)
|
||||
getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] =
|
||||
this.gitCommands.getRuntimeGitConflictOperation.bind(this.gitCommands)
|
||||
abortRuntimeGitMerge: RuntimeGitCommands['abortRuntimeGitMerge'] =
|
||||
this.gitCommands.abortRuntimeGitMerge.bind(this.gitCommands)
|
||||
getRuntimeGitDiff: RuntimeGitCommands['getRuntimeGitDiff'] =
|
||||
this.gitCommands.getRuntimeGitDiff.bind(this.gitCommands)
|
||||
getRuntimeGitBranchCompare: RuntimeGitCommands['getRuntimeGitBranchCompare'] =
|
||||
|
||||
@@ -197,6 +197,7 @@ describe('git RPC methods', () => {
|
||||
defaultModelId: 'auto'
|
||||
}),
|
||||
cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }),
|
||||
abortRuntimeGitMerge: vi.fn().mockResolvedValue({ ok: true }),
|
||||
pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }),
|
||||
getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3')
|
||||
} as unknown as OrcaRuntimeService
|
||||
@@ -216,6 +217,7 @@ describe('git RPC methods', () => {
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.cancelGenerateCommitMessage', { worktree: 'id:wt-1' })
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('git.abortMerge', { worktree: 'id:wt-1' }))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.push', {
|
||||
worktree: 'id:wt-1',
|
||||
@@ -237,6 +239,7 @@ describe('git RPC methods', () => {
|
||||
agentCmdOverrides: { cursor: 'cursor-agent' }
|
||||
})
|
||||
expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(runtime.abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(runtime.pushRuntimeGit).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
true,
|
||||
|
||||
@@ -51,6 +51,11 @@ export const GIT_METHODS: RpcMethod[] = [
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.getRuntimeGitConflictOperation(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.abortMerge',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.abortRuntimeGitMerge(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.diff',
|
||||
params: GitDiff,
|
||||
|
||||
@@ -761,6 +761,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
.fn()
|
||||
.mockResolvedValue({ hasUpstream: true, ahead: 1, behind: 0 })
|
||||
const rebaseRuntimeGitFromBase = vi.fn().mockResolvedValue({ ok: true })
|
||||
const abortRuntimeGitMerge = vi.fn().mockResolvedValue({ ok: true })
|
||||
const bulkStageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true })
|
||||
const bulkUnstageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true })
|
||||
const getRuntimeGitDiff = vi.fn().mockResolvedValue({
|
||||
@@ -842,6 +843,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
getRuntimeGitStatus,
|
||||
getRuntimeGitUpstreamStatus,
|
||||
rebaseRuntimeGitFromBase,
|
||||
abortRuntimeGitMerge,
|
||||
bulkStageRuntimeGitPaths,
|
||||
bulkUnstageRuntimeGitPaths,
|
||||
getRuntimeGitDiff,
|
||||
@@ -1400,6 +1402,16 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_git_abort_merge',
|
||||
method: 'git.abortMerge',
|
||||
deviceToken: mobile.token,
|
||||
params: { worktree: 'id:wt-1' }
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_git_bulk_unstage',
|
||||
@@ -1612,6 +1624,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
expect.objectContaining({ id: 'req_git_rebase_from_base', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_bulk_stage', ok: true }))
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_abort_merge', ok: true }))
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_git_bulk_unstage', ok: true })
|
||||
)
|
||||
@@ -1646,6 +1659,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined, undefined)
|
||||
expect(getRuntimeGitUpstreamStatus).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(bulkStageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['a.ts', 'b.ts'])
|
||||
expect(abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts'])
|
||||
expect(openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true)
|
||||
expect(getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', false, undefined)
|
||||
|
||||
@@ -145,6 +145,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'files.open',
|
||||
'files.openDiff',
|
||||
'files.read',
|
||||
'git.abortMerge',
|
||||
'git.bulkStage',
|
||||
'git.bulkUnstage',
|
||||
'git.commit',
|
||||
|
||||
@@ -1571,6 +1571,7 @@ export type PreloadApi = {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
}) => Promise<GitConflictOperation>
|
||||
abortMerge: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
|
||||
diff: (args: {
|
||||
worktreePath: string
|
||||
filePath: string
|
||||
|
||||
@@ -2130,6 +2130,8 @@ const api = {
|
||||
): Promise<GitHistoryResult> => ipcRenderer.invoke('git:history', args),
|
||||
conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('git:conflictOperation', args),
|
||||
abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('git:abortMerge', args),
|
||||
diff: (args: {
|
||||
worktreePath: string
|
||||
filePath: string
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('GitHandler', () => {
|
||||
expect(methods).toContain('git.unstage')
|
||||
expect(methods).toContain('git.bulkStage')
|
||||
expect(methods).toContain('git.bulkUnstage')
|
||||
expect(methods).toContain('git.abortMerge')
|
||||
expect(methods).toContain('git.discard')
|
||||
expect(methods).toContain('git.bulkDiscard')
|
||||
expect(methods).toContain('git.conflictOperation')
|
||||
@@ -64,6 +65,35 @@ describe('GitHandler', () => {
|
||||
expect(methods).toContain('git.isGitRepo')
|
||||
})
|
||||
|
||||
describe('abortMerge', () => {
|
||||
it('aborts an in-progress merge', async () => {
|
||||
gitInit(tmpDir)
|
||||
writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n')
|
||||
gitCommit(tmpDir, 'initial')
|
||||
const baseBranch = execFileSync('git', ['branch', '--show-current'], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
}).trim()
|
||||
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
||||
writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n')
|
||||
gitCommit(tmpDir, 'feature change')
|
||||
execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' })
|
||||
writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n')
|
||||
gitCommit(tmpDir, 'main change')
|
||||
|
||||
expect(() =>
|
||||
execFileSync('git', ['merge', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
||||
).toThrow()
|
||||
await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).resolves.toBeUndefined()
|
||||
|
||||
await dispatcher.callRequest('git.abortMerge', { worktreePath: tmpDir })
|
||||
|
||||
await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).rejects.toThrow()
|
||||
await expect(fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8')).resolves.toBe('main\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renameCurrentBranch', () => {
|
||||
it('renames only the checked-out branch through the narrow RPC', async () => {
|
||||
gitInit(tmpDir)
|
||||
|
||||
@@ -61,6 +61,7 @@ export class GitHandler {
|
||||
this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p))
|
||||
this.dispatcher.onRequest('git.bulkStage', (p) => this.bulkStage(p))
|
||||
this.dispatcher.onRequest('git.bulkUnstage', (p) => this.bulkUnstage(p))
|
||||
this.dispatcher.onRequest('git.abortMerge', (p) => this.abortMerge(p))
|
||||
this.dispatcher.onRequest('git.discard', (p) => this.discard(p))
|
||||
this.dispatcher.onRequest('git.bulkDiscard', (p) => this.bulkDiscard(p))
|
||||
this.dispatcher.onRequest('git.conflictOperation', (p) => this.conflictOperation(p))
|
||||
@@ -183,6 +184,11 @@ export class GitHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async abortMerge(params: Record<string, unknown>) {
|
||||
const worktreePath = params.worktreePath as string
|
||||
await this.git(['merge', '--abort'], worktreePath)
|
||||
}
|
||||
|
||||
private normalizeGitPathForCompare(filePath: string): string {
|
||||
return filePath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
@@ -333,4 +333,30 @@ describe('ConflictSummaryCard', () => {
|
||||
|
||||
expect(markup.indexOf('Resolve with AI')).toBeLessThan(markup.indexOf('Review conflicts'))
|
||||
})
|
||||
|
||||
it('shows Abort merge only for merge conflicts', () => {
|
||||
const mergeMarkup = renderToStaticMarkup(
|
||||
<ConflictSummaryCard
|
||||
conflictOperation="merge"
|
||||
unresolvedCount={1}
|
||||
isResolvingWithAI={false}
|
||||
onAbortMerge={vi.fn()}
|
||||
onResolveWithAI={vi.fn()}
|
||||
onReview={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const rebaseMarkup = renderToStaticMarkup(
|
||||
<ConflictSummaryCard
|
||||
conflictOperation="rebase"
|
||||
unresolvedCount={1}
|
||||
isResolvingWithAI={false}
|
||||
onAbortMerge={vi.fn()}
|
||||
onResolveWithAI={vi.fn()}
|
||||
onReview={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(mergeMarkup).toContain('Abort merge')
|
||||
expect(rebaseMarkup).not.toContain('Abort merge')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { BaseRefPicker } from '@/components/settings/BaseRefPicker'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog'
|
||||
import { formatDiffComment, formatDiffComments } from '@/lib/diff-comments-format'
|
||||
import { getDiffCommentLineLabel, getDiffCommentSource } from '@/lib/diff-comment-compat'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
@@ -115,6 +116,7 @@ import {
|
||||
} from '@/components/editor/editor-autosave'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import {
|
||||
abortRuntimeGitMerge,
|
||||
bulkDiscardRuntimeGitPaths,
|
||||
bulkStageRuntimeGitPaths,
|
||||
bulkUnstageRuntimeGitPaths,
|
||||
@@ -160,7 +162,7 @@ import {
|
||||
import { hasExpandedCommitFailureDetails, summarizeCommitFailure } from './commit-failure-summary'
|
||||
|
||||
export type SourceControlScope = 'all' | 'uncommitted'
|
||||
type RemoteActionError = { kind: RemoteOpKind; message: string }
|
||||
type SourceControlActionError = { kind: RemoteOpKind | 'abort_merge'; message: string }
|
||||
|
||||
const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = []
|
||||
const EMPTY_BRANCH_CHANGE_ENTRIES: GitBranchChangeEntry[] = []
|
||||
@@ -1119,7 +1121,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
const [commitDrafts, setCommitDrafts] = useState<CommitDraftsByWorktree>({})
|
||||
const [commitErrors, setCommitErrors] = useState<Record<string, string | null>>({})
|
||||
const [remoteActionErrors, setRemoteActionErrors] = useState<
|
||||
Record<string, RemoteActionError | null>
|
||||
Record<string, SourceControlActionError | null>
|
||||
>({})
|
||||
// Why: keep commit-in-flight state per-worktree. A single boolean would be
|
||||
// cleared when the user switched worktrees, letting them double-click Commit
|
||||
@@ -1128,6 +1130,11 @@ function SourceControlInner(): React.JSX.Element {
|
||||
const [commitInFlightByWorktree, setCommitInFlightByWorktree] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
)
|
||||
const [abortMergeInFlightByWorktree, setAbortMergeInFlightByWorktree] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
const isAbortingMerge = abortMergeInFlightByWorktree[activeWorktreeId ?? ''] ?? false
|
||||
const confirmAction = useConfirmationDialog()
|
||||
const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false
|
||||
// Why: parallel state to commit. Same per-worktree shape so navigating between
|
||||
// worktrees mid-generation never silently cancels the in-flight request.
|
||||
@@ -1677,6 +1684,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
setCommitErrors((prev) => pruneRecord(prev))
|
||||
setRemoteActionErrors((prev) => pruneRecord(prev))
|
||||
setCommitInFlightByWorktree((prev) => pruneRecord(prev))
|
||||
setAbortMergeInFlightByWorktree((prev) => pruneRecord(prev))
|
||||
setGenerateInFlightByWorktree((prev) => pruneRecord(prev))
|
||||
setGenerateErrors((prev) => pruneRecord(prev))
|
||||
setGitHistoryByWorktree((prev) => pruneRecord(prev))
|
||||
@@ -2013,6 +2021,53 @@ function SourceControlInner(): React.JSX.Element {
|
||||
]
|
||||
)
|
||||
|
||||
const handleAbortMerge = useCallback(async (): Promise<void> => {
|
||||
if (!activeWorktreeId || !worktreePath || conflictOperation !== 'merge' || isAbortingMerge) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await confirmAction({
|
||||
title: 'Abort merge?',
|
||||
description:
|
||||
'This cancels the merge in progress and can discard conflict resolutions made during this merge.',
|
||||
confirmLabel: 'Abort merge',
|
||||
confirmVariant: 'destructive'
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
|
||||
setAbortMergeInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
|
||||
setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
|
||||
try {
|
||||
await abortRuntimeGitMerge({
|
||||
settings: useAppStore.getState().settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
})
|
||||
await refreshActiveGitStatusAfterMutation()
|
||||
void refreshGitHistoryRef.current()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to abort merge'
|
||||
toast.error('Abort merge failed', { description: message })
|
||||
setRemoteActionErrors((prev) => ({
|
||||
...prev,
|
||||
[activeWorktreeId]: { kind: 'abort_merge', message }
|
||||
}))
|
||||
} finally {
|
||||
setAbortMergeInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
|
||||
}
|
||||
}, [
|
||||
activeWorktreeId,
|
||||
confirmAction,
|
||||
conflictOperation,
|
||||
isAbortingMerge,
|
||||
refreshActiveGitStatusAfterMutation,
|
||||
worktreePath
|
||||
])
|
||||
|
||||
// Why: compound actions must commit first and only run the follow-up remote
|
||||
// op when the commit succeeds. handleCommit's return value carries that
|
||||
// signal — a failure leaves commitError populated and short-circuits here
|
||||
@@ -2557,7 +2612,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
hasMessage: commitMessage.trim().length > 0,
|
||||
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
|
||||
isCommitting,
|
||||
isRemoteOperationActive,
|
||||
isRemoteOperationActive: isRemoteOperationActive || isAbortingMerge,
|
||||
upstreamStatus: remoteStatus,
|
||||
prState: hostedReview?.state ?? null,
|
||||
isPRStateLoading: isHostedReviewStateLoading,
|
||||
@@ -2575,6 +2630,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
hasUnstagedChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
isCommitting,
|
||||
isAbortingMerge,
|
||||
isRemoteOperationActive,
|
||||
inFlightRemoteOpKind,
|
||||
hostedReviewCreation,
|
||||
@@ -2596,7 +2652,8 @@ function SourceControlInner(): React.JSX.Element {
|
||||
hasMessage: commitMessage.trim().length > 0,
|
||||
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
|
||||
isCommitting,
|
||||
isRemoteOperationActive,
|
||||
isRemoteOperationActive: isRemoteOperationActive || isAbortingMerge,
|
||||
conflictOperation,
|
||||
upstreamStatus: remoteStatus,
|
||||
prState: hostedReview?.state ?? null,
|
||||
isPRStateLoading: isHostedReviewStateLoading,
|
||||
@@ -2613,6 +2670,8 @@ function SourceControlInner(): React.JSX.Element {
|
||||
hasUnstagedChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
isCommitting,
|
||||
conflictOperation,
|
||||
isAbortingMerge,
|
||||
isRemoteOperationActive,
|
||||
inFlightRemoteOpKind,
|
||||
hostedReviewCreation,
|
||||
@@ -2647,6 +2706,9 @@ function SourceControlInner(): React.JSX.Element {
|
||||
case 'commit_sync':
|
||||
void runCompoundCommitAction('sync')
|
||||
return
|
||||
case 'abort_merge':
|
||||
void handleAbortMerge()
|
||||
return
|
||||
case 'create_pr':
|
||||
void handleCreatePullRequest()
|
||||
return
|
||||
@@ -2673,6 +2735,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
[
|
||||
handleCommit,
|
||||
handleCreatePullRequest,
|
||||
handleAbortMerge,
|
||||
isCreatingPr,
|
||||
prGenerating,
|
||||
runCompoundCommitAction,
|
||||
@@ -3926,6 +3989,8 @@ function SourceControlInner(): React.JSX.Element {
|
||||
conflictOperation={conflictOperation}
|
||||
unresolvedCount={unresolvedConflictReviewEntries.length}
|
||||
isResolvingWithAI={isLaunchingConflictAgent}
|
||||
isAbortingMerge={isAbortingMerge}
|
||||
onAbortMerge={() => void handleAbortMerge()}
|
||||
onResolveWithAI={() => {
|
||||
void handleResolveConflictsWithAI()
|
||||
}}
|
||||
@@ -3949,7 +4014,11 @@ function SourceControlInner(): React.JSX.Element {
|
||||
ConflictSummaryCard handles the "has conflicts" case above. */}
|
||||
{unresolvedConflictReviewEntries.length === 0 && conflictOperation !== 'unknown' && (
|
||||
<div className="px-3 pb-2">
|
||||
<OperationBanner conflictOperation={conflictOperation} />
|
||||
<OperationBanner
|
||||
conflictOperation={conflictOperation}
|
||||
isAbortingMerge={isAbortingMerge}
|
||||
onAbortMerge={() => void handleAbortMerge()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4042,7 +4111,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
generateError={generateError}
|
||||
stagedCount={grouped.staged.length}
|
||||
hasUnresolvedConflicts={unresolvedConflicts.length > 0}
|
||||
isRemoteOperationActive={isRemoteOperationActive}
|
||||
isRemoteOperationActive={isRemoteOperationActive || isAbortingMerge}
|
||||
inFlightRemoteOpKind={inFlightRemoteOpKind}
|
||||
primaryAction={primaryAction}
|
||||
dropdownItems={dropdownItems}
|
||||
@@ -4763,6 +4832,7 @@ function PullRequestComposer({
|
||||
key={entry.kind}
|
||||
disabled={entry.disabled}
|
||||
title={entry.title}
|
||||
variant={entry.variant}
|
||||
onSelect={(event) => {
|
||||
if (entry.disabled) {
|
||||
event.preventDefault()
|
||||
@@ -5205,6 +5275,7 @@ export function CommitArea({
|
||||
<DropdownMenuItem
|
||||
disabled={entry.disabled}
|
||||
title={entry.title}
|
||||
variant={entry.variant}
|
||||
className="w-full"
|
||||
onSelect={(event) => {
|
||||
if (entry.disabled) {
|
||||
@@ -5692,12 +5763,16 @@ export function ConflictSummaryCard({
|
||||
conflictOperation,
|
||||
unresolvedCount,
|
||||
isResolvingWithAI,
|
||||
isAbortingMerge = false,
|
||||
onAbortMerge,
|
||||
onResolveWithAI,
|
||||
onReview
|
||||
}: {
|
||||
conflictOperation: GitConflictOperation
|
||||
unresolvedCount: number
|
||||
isResolvingWithAI: boolean
|
||||
isAbortingMerge?: boolean
|
||||
onAbortMerge?: () => void
|
||||
onResolveWithAI: () => void
|
||||
onReview: () => void
|
||||
}): React.JSX.Element {
|
||||
@@ -5750,6 +5825,19 @@ export function ConflictSummaryCard({
|
||||
<GitMerge className="size-3.5" />
|
||||
Review conflicts
|
||||
</Button>
|
||||
{conflictOperation === 'merge' && onAbortMerge ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-1.5 h-7 w-full text-xs"
|
||||
disabled={isResolvingWithAI || isAbortingMerge}
|
||||
onClick={onAbortMerge}
|
||||
>
|
||||
{isAbortingMerge ? <RefreshCw className="size-3.5 animate-spin" /> : null}
|
||||
Abort merge
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -5761,9 +5849,13 @@ export function ConflictSummaryCard({
|
||||
// user needs to see the operation state so they know the worktree is mid-rebase
|
||||
// and that they should run `git rebase --continue` or `--abort`.
|
||||
function OperationBanner({
|
||||
conflictOperation
|
||||
conflictOperation,
|
||||
isAbortingMerge = false,
|
||||
onAbortMerge
|
||||
}: {
|
||||
conflictOperation: GitConflictOperation
|
||||
isAbortingMerge?: boolean
|
||||
onAbortMerge?: () => void
|
||||
}): React.JSX.Element {
|
||||
const label =
|
||||
conflictOperation === 'merge'
|
||||
@@ -5782,6 +5874,19 @@ function OperationBanner({
|
||||
<Icon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-xs font-medium text-foreground">{label}</span>
|
||||
</div>
|
||||
{conflictOperation === 'merge' && onAbortMerge ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-2 h-7 w-full text-xs"
|
||||
disabled={isAbortingMerge}
|
||||
onClick={onAbortMerge}
|
||||
>
|
||||
{isAbortingMerge ? <RefreshCw className="size-3.5 animate-spin" /> : null}
|
||||
Abort merge
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,6 +214,46 @@ describe('resolveDropdownItems', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a destructive Abort merge item only while a merge is in progress', () => {
|
||||
const mergeItems = resolveDropdownItems(
|
||||
inputs({
|
||||
conflictOperation: 'merge',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
|
||||
})
|
||||
)
|
||||
const mergeByKind = Object.fromEntries(
|
||||
mergeItems.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
|
||||
expect(mergeByKind.abort_merge).toMatchObject({
|
||||
label: 'Abort merge',
|
||||
title: 'Abort the merge in progress',
|
||||
disabled: false,
|
||||
variant: 'destructive'
|
||||
})
|
||||
|
||||
for (const conflictOperation of ['unknown', 'rebase', 'cherry-pick'] as const) {
|
||||
const items = resolveDropdownItems(inputs({ conflictOperation }))
|
||||
expect(items.some((entry) => entry.kind === 'abort_merge')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('disables Abort merge while another action is busy', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
conflictOperation: 'merge',
|
||||
isRemoteOperationActive: true,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
|
||||
})
|
||||
)
|
||||
const abortMerge = items.find((entry) => entry.kind === 'abort_merge')
|
||||
|
||||
expect(abortMerge).toMatchObject({
|
||||
disabled: true,
|
||||
title: 'Operation in progress…'
|
||||
})
|
||||
})
|
||||
|
||||
it('locks every item while a pull request operation is running', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// Why: split from source-control-primary-action because the primary and dropdown are independent derivations with different priority ladders; together they exceed the max-lines budget and tangle unrelated concerns.
|
||||
|
||||
import type { PrimaryActionInputs } from './source-control-primary-action'
|
||||
import type { GitConflictOperation } from '../../../../shared/types'
|
||||
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
|
||||
|
||||
export type DropdownActionInputs = PrimaryActionInputs & {
|
||||
conflictOperation?: GitConflictOperation
|
||||
isPullRequestOperationActive?: boolean
|
||||
rebaseBaseRef?: string | null
|
||||
}
|
||||
@@ -13,6 +15,7 @@ export type DropdownActionKind =
|
||||
| 'commit'
|
||||
| 'commit_push'
|
||||
| 'commit_sync'
|
||||
| 'abort_merge'
|
||||
| 'create_pr'
|
||||
| 'push_create_pr'
|
||||
| 'push'
|
||||
@@ -28,6 +31,7 @@ export type DropdownItem = {
|
||||
title: string
|
||||
disabled: boolean
|
||||
hint?: string
|
||||
variant?: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
export type DropdownSeparator = { kind: 'separator' }
|
||||
@@ -86,6 +90,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
|
||||
prState,
|
||||
isPRStateLoading,
|
||||
hostedReviewCreation,
|
||||
conflictOperation = 'unknown',
|
||||
branchCommitsAhead,
|
||||
rebaseBaseRef,
|
||||
isPullRequestOperationActive = false
|
||||
@@ -414,6 +419,18 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
|
||||
fetchItem,
|
||||
publishItem
|
||||
]
|
||||
if (conflictOperation === 'merge') {
|
||||
entries.push(
|
||||
{ kind: 'separator' },
|
||||
{
|
||||
kind: 'abort_merge',
|
||||
label: 'Abort merge',
|
||||
title: globalBusy ? 'Operation in progress…' : 'Abort the merge in progress',
|
||||
disabled: globalBusy,
|
||||
variant: 'destructive'
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!isPullRequestOperationActive) {
|
||||
return entries
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from './runtime-compatibility-test-fixture'
|
||||
import { abortRuntimeGitMerge } from './runtime-git-client'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
|
||||
|
||||
const gitAbortMerge = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
const runtimeCall = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
gitAbortMerge.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
runtimeCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
git: { abortMerge: gitAbortMerge },
|
||||
runtime: { call: runtimeCall },
|
||||
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime git client merge operations', () => {
|
||||
it('uses local git IPC when no remote runtime is active', async () => {
|
||||
gitAbortMerge.mockResolvedValue(undefined)
|
||||
|
||||
await abortRuntimeGitMerge({
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
})
|
||||
|
||||
expect(gitAbortMerge).toHaveBeenCalledWith({ connectionId: undefined, worktreePath: '/repo' })
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes abort merge through the active runtime', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { success: true },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
await abortRuntimeGitMerge({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
})
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'git.abortMerge',
|
||||
params: { worktree: 'wt-1' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(gitAbortMerge).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -167,6 +167,23 @@ export async function getRuntimeGitConflictOperation(
|
||||
)
|
||||
}
|
||||
|
||||
export async function abortRuntimeGitMerge(context: RuntimeGitContext): Promise<void> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
if (target.kind === 'local' || !context.worktreeId) {
|
||||
await window.api.git.abortMerge({
|
||||
worktreePath: context.worktreePath,
|
||||
connectionId: context.connectionId
|
||||
})
|
||||
return
|
||||
}
|
||||
await callRuntimeRpc(
|
||||
target,
|
||||
'git.abortMerge',
|
||||
{ worktree: context.worktreeId },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function getRuntimeGitDiff(
|
||||
context: RuntimeGitContext,
|
||||
args: { filePath: string; staged: boolean; compareAgainstHead?: boolean }
|
||||
|
||||
@@ -1063,6 +1063,10 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
|
||||
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
|
||||
return callRuntimeResult('git.conflictOperation', { worktree: worktree.id })
|
||||
},
|
||||
abortMerge: async ({ worktreePath }) => {
|
||||
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
|
||||
await callRuntimeResult('git.abortMerge', { worktree: worktree.id })
|
||||
},
|
||||
diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => {
|
||||
const file = await resolveRuntimeFilePath(filePath, worktreePath)
|
||||
return callRuntimeResult('git.diff', {
|
||||
|
||||
Reference in New Issue
Block a user