From d857e09615e56ce17128662a814b71030f9151ed Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:43:30 -0800 Subject: [PATCH] Git Graph: commit actions (checkout, cherry-pick, revert) (#39069) Adds commit actions to the Git Graph: check out a commit or branch, cherry-pick, revert, and create a branch from a commit. Conflicts hand off to Source Control with continue and abort. Works on local, WSL and remote hosts. --- src/main/git/branch-switch-real-git.test.ts | 129 ++++++- .../git/branch-switch-target-real-git.test.ts | 48 ++- src/main/git/branch-switch-target.ts | 56 ++- src/main/git/branch-switch.ts | 110 ++++-- src/main/git/checkout.ts | 4 +- ...sl-command-resolution-object-store.test.ts | 12 + .../command-runner/wsl-command-resolution.ts | 9 +- src/main/git/commit-actions-real-git.test.ts | 361 ++++++++++++++++++ src/main/git/commit-actions.ts | 229 +++++++++++ src/main/git/create-branch.ts | 33 ++ .../source-control/git-conflict-operation.ts | 11 +- .../git/source-control/git-pathspec.test.ts | 17 + src/main/git/source-control/git-pathspec.ts | 8 +- .../desktop-renderer-runtime-capabilities.ts | 2 + .../filesystem-git-commit-handlers.ts | 60 +++ src/main/providers/git-provider-contract.ts | 22 +- src/main/providers/local-git-provider.ts | 19 +- .../integrations/git/orca-runtime-git.ts | 10 + .../git/runtime-branch-switch-host.test.ts | 36 ++ .../git/runtime-branch-switch-host.ts | 7 +- .../git/runtime-git-command-surface.ts | 10 + .../git/runtime-git-command-target.ts | 5 +- .../git/runtime-git-status-commands.ts | 14 +- .../git/runtime-git-sync-commands.ts | 43 +++ .../runtime/rpc/dispatcher-error-response.ts | 6 + .../git/git-commit-action-methods.ts | 44 +++ .../git/git-conflict-projection.test.ts | 60 +++ .../source-control/git/git-status-methods.ts | 86 +++++ .../rpc/methods/source-control/git/git.ts | 57 +-- .../workspace-layout-apply.test.ts | 10 + src/preload/api/git-bridge.ts | 30 ++ src/preload/api/git-operation-api.ts | 30 ++ .../git-graph-commit-action-controls.tsx | 129 +++++++ .../git-graph/git-graph-commit-action-copy.ts | 108 ++++++ ...it-graph-commit-actions-controller.test.ts | 217 +++++++++++ .../git-graph-commit-actions-controller.ts | 255 +++++++++++++ .../editor/git-graph/git-graph-details.tsx | 4 + .../git-graph/git-graph-history-controller.ts | 43 +-- .../git-graph/git-graph-history-view.ts | 9 + .../editor/git-graph/git-graph-ref-menu.tsx | 36 +- .../git-graph/git-graph-rendering.test.tsx | 141 +++++-- .../git-graph/git-graph-tab-controllers.ts | 17 + .../editor/git-graph/git-graph-tab.tsx | 32 +- .../git-graph-workspace.test-fixtures.ts | 32 ++ .../editor/git-graph/use-git-graph-history.ts | 18 +- .../retention/closed-editor-tab-controller.ts | 2 +- .../commit/component-gates.test.ts | 8 + .../source-control/commit/component-gates.ts | 9 +- .../listing/conflict-status-cards.tsx | 26 +- .../source-control/listing/content-status.tsx | 21 +- .../sequencer-recovery-controls.test.tsx | 144 +++++++ .../listing/sequencer-recovery-controls.tsx | 180 +++++++++ .../source-control/panel/panel-content.tsx | 12 +- .../sync/git-history-commit-context-menu.tsx | 3 + .../sync/git-history-graph-svg.tsx | 112 +++--- .../metadata/worktree-conflict-label.ts | 2 + src/renderer/src/i18n/locales/de.json | 37 +- src/renderer/src/i18n/locales/en.json | 37 +- src/renderer/src/i18n/locales/es.json | 37 +- src/renderer/src/i18n/locales/fr.json | 37 +- src/renderer/src/i18n/locales/ja.json | 37 +- src/renderer/src/i18n/locales/ko.json | 37 +- src/renderer/src/i18n/locales/pt.json | 37 +- src/renderer/src/i18n/locales/ru.json | 37 +- src/renderer/src/i18n/locales/tr.json | 37 +- src/renderer/src/i18n/locales/zh-Hant.json | 37 +- src/renderer/src/i18n/locales/zh.json | 37 +- .../git/git-commit-actions-capability.ts | 41 ++ .../git/runtime-git-branch-client.ts | 13 +- .../git/runtime-git-commit-actions-client.ts | 115 ++++++ .../src/web/preload-api/web-git-api.ts | 2 + .../preload-api/web-git-commit-actions-api.ts | 55 +++ ...tron-remote-runtime-client-capabilities.ts | 2 + src/shared/git/commands/git-commit-action.ts | 10 + src/shared/git/status/git-status-types.ts | 2 +- src/shared/rpc-contract/git-params.ts | 17 +- .../rpc-params-catalog.generated.ts | 8 + src/shared/runtime-worktree-contracts.ts | 2 +- .../runtime/git-graph-capabilities.ts | 1 + .../source-control-runtime-capabilities.ts | 7 +- src/shared/web-runtime-client-capabilities.ts | 2 + 81 files changed, 3509 insertions(+), 313 deletions(-) create mode 100644 src/main/git/commit-actions-real-git.test.ts create mode 100644 src/main/git/commit-actions.ts create mode 100644 src/main/git/create-branch.ts create mode 100644 src/main/git/source-control/git-pathspec.test.ts create mode 100644 src/main/runtime/integrations/git/runtime-branch-switch-host.test.ts create mode 100644 src/main/runtime/rpc/methods/source-control/git/git-commit-action-methods.ts create mode 100644 src/main/runtime/rpc/methods/source-control/git/git-conflict-projection.test.ts create mode 100644 src/main/runtime/rpc/methods/source-control/git/git-status-methods.ts create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-commit-action-controls.tsx create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-commit-action-copy.ts create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-commit-actions-controller.test.ts create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-commit-actions-controller.ts create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-history-view.ts create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-tab-controllers.ts create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-workspace.test-fixtures.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control/commit/component-gates.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/sequencer-recovery-controls.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/sequencer-recovery-controls.tsx create mode 100644 src/renderer/src/runtime/source-control/git/git-commit-actions-capability.ts create mode 100644 src/renderer/src/runtime/source-control/git/runtime-git-commit-actions-client.ts create mode 100644 src/renderer/src/web/preload-api/web-git-commit-actions-api.ts create mode 100644 src/shared/git/commands/git-commit-action.ts diff --git a/src/main/git/branch-switch-real-git.test.ts b/src/main/git/branch-switch-real-git.test.ts index 27459edbed9..6afc08cad6d 100644 --- a/src/main/git/branch-switch-real-git.test.ts +++ b/src/main/git/branch-switch-real-git.test.ts @@ -1,14 +1,19 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { runProcess } from '@orca/process-host' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { installRealWorkerThreadChildProcesses } from '../worker-thread-child-process/worker-thread-child-process-real.test-fixtures' -import { switchBranchGuarded, type BranchSwitchDeps } from './branch-switch' +import { + BranchSwitchLandedWithWarningError, + switchBranchGuarded, + type BranchSwitchDeps +} from './branch-switch' import { checkoutBranch } from './checkout' import { getStatus } from './source-control/status-read' let directory: string +let initialHead: string async function git(...args: string[]): Promise { const result = await runProcess({ program: 'git', args, cwd: directory }) @@ -18,10 +23,13 @@ async function git(...args: string[]): Promise { return result.stdout.trim() } -function deps(agentBlocker: string | null = null): BranchSwitchDeps { +function deps(agentBlocker: string | null = null, target = 'feature'): BranchSwitchDeps { return { getStatus: () => getStatus(directory, { includeLineStats: false }), - checkout: () => checkoutBranch(directory, 'feature'), + checkout: () => + checkoutBranch(directory, target, { + detached: /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(target) + }), getAgentBlocker: async () => agentBlocker } } @@ -42,6 +50,7 @@ beforeEach(async () => { await git('add', 'file.txt') await git('commit', '-qm', 'base') await git('branch', 'feature') + initialHead = await git('rev-parse', 'HEAD') }) afterEach(async () => { @@ -49,10 +58,120 @@ afterEach(async () => { }) describe('switchBranchGuarded', () => { + it.each([false, true])( + 'keeps the checkout refusal when the landed HEAD read fails: %s', + async (missingHead) => { + const oid = await git('rev-parse', 'HEAD') + await writeFile(join(directory, '.git', 'HEAD.lock'), '') + const dependencies = deps(null, oid) + const checkout = dependencies.checkout + dependencies.checkout = async (...args) => { + try { + return await checkout(...args) + } catch (error) { + if (missingHead) { + await rename(join(directory, '.git', 'HEAD'), join(directory, '.git', 'saved-HEAD')) + } + throw error + } + } + try { + await switchBranchGuarded(directory, { commitId: oid }, {}, dependencies) + throw new Error('Expected checkout to fail') + } catch (error) { + expect(error).not.toBeInstanceOf(BranchSwitchLandedWithWarningError) + expect(String(error)).toContain('HEAD.lock') + expect(String(error)).not.toContain('Command failed:') + expect(String(error)).not.toContain('\n') + } finally { + if (missingHead) { + await rename(join(directory, '.git', 'saved-HEAD'), join(directory, '.git', 'HEAD')) + } + } + expect(await currentBranch()).toBe('main') + } + ) + it('leaves a reachable detached commit and actually checks out the branch', async () => { + await git('checkout', '--detach', '-q') + const result = await switchBranchGuarded(directory, 'feature', {}, deps()) + expect(result).toEqual({ branch: 'feature', previousBranch: null, head: initialHead }) + expect(await currentBranch()).toBe('feature') + }) + + it('refuses to abandon a detached commit not reachable from branches, remotes or tags', async () => { + await git('checkout', '--detach', '-q') + await git('commit', '--allow-empty', '-qm', 'detached work') + await expect(switchBranchGuarded(directory, 'feature', {}, deps())).rejects.toThrow( + 'This checkout is on a detached commit. Switch branches from a terminal so commits made here are not lost.' + ) + expect(await git('log', '-1', '--format=%s')).toBe('detached work') + }) + + it('detaches at the commit even when a branch has the same full object ID as its name', async () => { + const oid = await git('rev-parse', 'HEAD') + await git('commit', '--allow-empty', '-qm', 'later') + await git('branch', oid) + await switchBranchGuarded(directory, { commitId: oid }, {}, deps(null, oid)) + expect(await git('log', '-1', '--format=%s')).toBe('base') + expect(await git('branch', '--show-current')).toBe('') + await switchBranchGuarded( + directory, + oid, + {}, + { ...deps(), checkout: () => checkoutBranch(directory, oid) } + ) + expect(await currentBranch()).toBe(oid) + expect(await git('log', '-1', '--format=%s')).toBe('later') + }) + + it('moves between distinct reachable detached commits', async () => { + const target = await git('rev-parse', 'HEAD') + await git('commit', '--allow-empty', '-qm', 'later') + await git('checkout', '--detach', '-q') + await switchBranchGuarded(directory, { commitId: target }, {}, deps(null, target)) + expect(await git('log', '-1', '--format=%s')).toBe('base') + expect(await git('branch', '--show-current')).toBe('') + }) + + it('does not check out a detached commit that is already HEAD', async () => { + const oid = await git('rev-parse', 'HEAD') + await git('checkout', '--detach', '-q') + const hooks = join(directory, '.git', 'hooks') + await git('config', 'core.hooksPath', hooks) + await mkdir(hooks, { recursive: true }) + await writeFile(join(hooks, 'post-checkout'), '#!/bin/sh\necho should-not-run >&2\nexit 1\n') + await chmod(join(hooks, 'post-checkout'), 0o755) + await expect( + switchBranchGuarded(directory, { commitId: oid }, {}, deps(null, oid)) + ).resolves.toEqual({ + branch: oid, + previousBranch: null, + head: oid + }) + }) + + it('reports a landed commit checkout even when its post-checkout hook fails', async () => { + const oid = await git('rev-parse', 'HEAD') + await git('commit', '--allow-empty', '-qm', 'later') + const hooks = join(directory, '.git', 'hooks') + await git('config', 'core.hooksPath', hooks) + await mkdir(hooks, { recursive: true }) + await writeFile(join(hooks, 'post-checkout'), '#!/bin/sh\necho hook-failed >&2\nexit 1\n') + await chmod(join(hooks, 'post-checkout'), 0o755) + await expect( + switchBranchGuarded(directory, { commitId: oid }, {}, deps(null, oid)) + ).rejects.toMatchObject({ + name: 'BranchSwitchLandedWithWarningError', + message: `Switched to ${oid}. A git hook in this repository failed afterwards and said: hook-failed. The switch itself worked.` + }) + expect(await git('rev-parse', 'HEAD')).toBe(oid) + expect(await git('branch', '--show-current')).toBe('') + }) + it('switches a clean checkout and reports the branch it left', async () => { const result = await switchBranchGuarded(directory, 'feature', {}, deps()) - expect(result).toEqual({ branch: 'feature', previousBranch: 'main' }) + expect(result).toEqual({ branch: 'feature', previousBranch: 'main', head: initialHead }) expect(await currentBranch()).toBe('feature') }) diff --git a/src/main/git/branch-switch-target-real-git.test.ts b/src/main/git/branch-switch-target-real-git.test.ts index eeb93ab2435..ff230dbfbc6 100644 --- a/src/main/git/branch-switch-target-real-git.test.ts +++ b/src/main/git/branch-switch-target-real-git.test.ts @@ -5,13 +5,14 @@ import { runProcess } from '@orca/process-host' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { BranchSwitchTarget } from '../../shared/git/branch-switch-target' import { installRealWorkerThreadChildProcesses } from '../worker-thread-child-process/worker-thread-child-process-real.test-fixtures' -import { BranchSwitchLandedWithWarningError, switchBranchGuarded } from './branch-switch' +import { switchBranchGuarded } from './branch-switch' import { BranchSwitchCollisionError } from './branch-switch-target' import { checkoutBranch, listLocalBranches } from './checkout' import { getStatus } from './source-control/status-read' import { searchRemoteRefPage } from './repo-remote-ref-search' let directory: string +let initialHead: string async function git(...args: string[]): Promise { const result = await runProcess({ program: 'git', @@ -56,6 +57,7 @@ beforeEach(async () => { await writeFile(join(directory, 'file.txt'), 'base\n') await git('add', 'file.txt') await git('commit', '-qm', 'base') + initialHead = await git('rev-parse', 'HEAD') }) afterEach(async () => { await rm(directory, { recursive: true, force: true }) @@ -66,7 +68,8 @@ it('switches an exact local target and records it', async () => { const recorded = vi.fn() expect(await switchTo({ kind: 'local', name: 'feature' }, null, recorded)).toEqual({ branch: 'feature', - previousBranch: 'main' + previousBranch: 'main', + head: initialHead }) expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('feature') expect(recorded).toHaveBeenCalledOnce() @@ -84,12 +87,17 @@ it.each(['team/upstream', 'origin'])( expect(await git('rev-parse', `refs/heads/${name}/topic`)).toBe(await git('rev-parse', 'main')) } ) -it('reuses a differently named local tracker', async () => { +it('returns the advanced HEAD when reusing a differently named local tracker', async () => { await remote('origin') await git('branch', '--track', 'my-topic', 'refs/remotes/origin/topic') + await git('checkout', '-q', 'my-topic') + await git('commit', '--allow-empty', '-qm', 'local advance') + const advanced = await git('rev-parse', 'HEAD') + await git('checkout', '-q', 'main') expect(await switchTo({ kind: 'remote', name: 'origin/topic' })).toEqual({ branch: 'my-topic', - previousBranch: 'main' + previousBranch: 'main', + head: advanced }) expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('my-topic') expect((await listLocalBranches(directory)).branches).not.toContain('topic') @@ -130,6 +138,14 @@ it('carries uncommitted changes onto a new branch but refuses an existing branch expect(await git('ls-files', '--others', '--exclude-standard')).toBe('draft.txt') expect(await readFile(join(directory, 'draft.txt'), 'utf8')).toBe('draft\n') }) +it('refuses creation of the current branch without recording a switch', async () => { + const recorded = vi.fn() + await expect(switchTo({ kind: 'new', name: 'main' }, null, recorded)).rejects.toMatchObject({ + data: { existingBranch: 'main' } + }) + expect(recorded).not.toHaveBeenCalled() + expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('main') +}) it('offers direct local collision recovery without relying on discovery', async () => { await git('branch', 'topic') await expect(switchTo({ kind: 'new', name: 'topic' })).rejects.toMatchObject({ @@ -152,16 +168,15 @@ it('probes the resolved local name when a remote checkout lands with a hook fail const recorded = vi.fn() await expect( switchTo({ kind: 'remote', name: 'team/upstream/topic' }, null, recorded) - ).rejects.toThrow( - new BranchSwitchLandedWithWarningError( - 'Switched to topic. A git hook in this repository failed afterwards and said: hook failed. The switch itself worked.', - 'topic' - ) - ) + ).rejects.toMatchObject({ + name: 'BranchSwitchLandedWithWarningError', + branch: 'topic', + head: initialHead + }) expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('topic') expect(recorded).toHaveBeenCalledOnce() }) -it("quotes git's own error without blaming a hook when a switch lands", async () => { +it('reports the landed HEAD when git errors after checkout', async () => { await git('branch', 'topic') await expect( switchBranchGuarded( @@ -179,12 +194,11 @@ it("quotes git's own error without blaming a hook when a switch lands", async () getAgentBlocker: async () => null } ) - ).rejects.toThrow( - new BranchSwitchLandedWithWarningError( - 'Switched to topic, but git reported: error: unable to create file app.css: Permission denied', - 'topic' - ) - ) + ).rejects.toMatchObject({ + name: 'BranchSwitchLandedWithWarningError', + branch: 'topic', + head: initialHead + }) }) it('maps a remote not covered by its fetch settings to tracking recovery', async () => { await remote('origin') diff --git a/src/main/git/branch-switch-target.ts b/src/main/git/branch-switch-target.ts index 925cf864062..e8af2ba1d2e 100644 --- a/src/main/git/branch-switch-target.ts +++ b/src/main/git/branch-switch-target.ts @@ -16,6 +16,52 @@ export class BranchSwitchCollisionError extends Error { } } +export async function readCurrentBranch( + worktreePath: string, + options: GitRuntimeOptions +): Promise { + try { + const { stdout } = await gitExecFileAsync( + ['symbolic-ref', '--quiet', '--short', 'HEAD'], + gitOptionsForWorktree(worktreePath, options) + ) + return stdout.trim() || null + } catch { + return null + } +} + +export async function readBranchSwitchHead( + worktreePath: string, + options: GitRuntimeOptions +): Promise { + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', 'HEAD'], + gitOptionsForWorktree(worktreePath, options) + ) + return stdout.trim() || undefined + } catch { + return undefined + } +} + +export async function validateNewBranchName( + path: string, + name: string, + options: GitRuntimeOptions +): Promise { + if (!isBranchNameValid(name)) { + throw new Error('This branch name is invalid. Choose a different name.') + } + await gitExecFileAsync( + ['check-ref-format', '--branch', name], + gitOptionsForWorktree(path, options) + ).catch(() => { + throw new Error('This branch name is invalid. Choose a different name.') + }) +} + export async function resolveBranchSwitchTarget( path: string, target: BranchSwitchTarget, @@ -23,15 +69,13 @@ export async function resolveBranchSwitchTarget( ): Promise<{ branch: string; creation?: BranchSwitchCreation }> { const execution = gitOptionsForWorktree(path, { ...options, admissionTier: 'interactive' }) const run = (args: string[]) => gitExecFileAsync(args, execution) + if (target.kind === 'new') { + await validateNewBranchName(path, target.name, { ...options, admissionTier: 'interactive' }) + return { branch: target.name, creation: { kind: 'new' } } + } if (!isBranchNameValid(target.name)) { throw new Error('This branch name is invalid. Choose a different name.') } - if (target.kind === 'new') { - await run(['check-ref-format', '--branch', target.name]).catch(() => { - throw new Error('This branch name is invalid. Choose a different name.') - }) - return { branch: target.name, creation: { kind: 'new' } } - } const full = target.kind === 'local' ? `refs/heads/${target.name}` diff --git a/src/main/git/branch-switch.ts b/src/main/git/branch-switch.ts index e0d8d01f7b4..363e018ba73 100644 --- a/src/main/git/branch-switch.ts +++ b/src/main/git/branch-switch.ts @@ -5,7 +5,9 @@ import type { import { resolveBranchSwitchTarget, branchSwitchFailure, - branchSwitchTrackingFailure + branchSwitchTrackingFailure, + readCurrentBranch, + readBranchSwitchHead } from './branch-switch-target' import { access } from 'node:fs/promises' import * as path from 'node:path' @@ -30,7 +32,8 @@ export class BranchSwitchRefusedError extends Error { export class BranchSwitchLandedWithWarningError extends Error { constructor( message: string, - readonly branch: string + readonly branch: string, + readonly head?: string ) { super(message) this.name = 'BranchSwitchLandedWithWarningError' @@ -48,7 +51,7 @@ const GIT_CHECKOUT_PROGRESS = /^(Switched to( a new)? branch|Already on|Your branch|Previous HEAD position|HEAD is now at)\b/ /** Prefer git's own error line; else the last line that is not git progress (a hook's output). */ -function gitFailureLine(stderr: string): string | null { +export function gitFailureLine(stderr: string): string | null { const lines = stderr .split('\n') .map((line) => line.trim()) @@ -57,21 +60,6 @@ function gitFailureLine(stderr: string): string | null { return lines.find((line) => /^(error|fatal):/.test(line)) ?? lines.at(-1) ?? null } -async function readCurrentBranch( - worktreePath: string, - options: GitRuntimeOptions -): Promise { - try { - const { stdout } = await gitExecFileAsync( - ['symbolic-ref', '--quiet', '--short', 'HEAD'], - gitOptionsForWorktree(worktreePath, options) - ) - return stdout.trim() || null - } catch { - return null - } -} - export type BranchSwitchDeps = { /** Status as the panel reads it, so Orca's own shared-path symlinks don't count as changes. */ getStatus: () => Promise @@ -85,6 +73,7 @@ export type BranchSwitchDeps = { export type BranchSwitchResult = { branch: string + head?: string /** The live branch the checkout left, or null when it was already on `branch`. */ previousBranch: string | null } @@ -179,27 +168,49 @@ async function readRefusal( branch: string, creating: boolean, options: GitRuntimeOptions, - deps: BranchSwitchDeps -): Promise<{ refusal: string | null; previousBranch: string | null }> { + deps: BranchSwitchDeps, + commitTarget: boolean +): Promise<{ refusal: string | null; previousBranch: string | null; alreadyOnTarget: boolean }> { const status = await deps.getStatus() if (status.readError !== undefined) { return { refusal: `Orca couldn't read this checkout's changes, so it didn't switch branches. ${status.readError}`, - previousBranch: null + previousBranch: null, + alreadyOnTarget: false } } const current = status.branch?.replace(/^refs\/heads\//, '') - if (current === branch) { - return { refusal: null, previousBranch: null } + if ( + !creating && + ((!commitTarget && current === branch) || + (current === undefined && commitTarget && status.head === branch)) + ) { + return { refusal: null, previousBranch: null, alreadyOnTarget: true } } - const refuse = (refusal: string | null) => ({ refusal, previousBranch: current ?? null }) + const refuse = (refusal: string | null) => ({ + refusal, + previousBranch: current ?? null, + alreadyOnTarget: false + }) if (status.head === '(initial)') { return refuse('Make a first commit on this branch before switching branches.') } if (current === undefined) { - return refuse( - 'This checkout is on a detached commit. Switch branches from a terminal so commits made here are not lost.' - ) + try { + const { stdout } = await gitExecFileAsync( + ['rev-list', '-n1', 'HEAD', '--not', '--branches', '--remotes', '--tags'], + gitOptionsForWorktree(worktreePath, options) + ) + if (stdout.trim()) { + return refuse( + 'This checkout is on a detached commit. Switch branches from a terminal so commits made here are not lost.' + ) + } + } catch (error) { + return refuse( + `Orca couldn't read this checkout's changes, so it didn't switch branches. ${error instanceof Error ? error.message : String(error)}` + ) + } } const revertInProgress = await exists( path.join(await resolveGitDir(worktreePath, options), 'REVERT_HEAD') @@ -232,29 +243,37 @@ async function readRefusal( /** Switch or create a branch in place, refusing when the checkout is not safe to switch. */ export async function switchBranchGuarded( worktreePath: string, - target: string | BranchSwitchTarget, + target: string | BranchSwitchTarget | { commitId: string }, options: GitRuntimeOptions, deps: BranchSwitchDeps ): Promise { + const commitTarget = typeof target !== 'string' && 'commitId' in target + const commitId = commitTarget ? target.commitId.toLowerCase() : undefined + if (commitId !== undefined && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commitId)) { + throw new Error('Expected a full git object id') + } return withCheckoutLock(worktreePath, async () => { const resolved: { branch: string; creation?: BranchSwitchCreation } = typeof target === 'string' ? { branch: target } - : await resolveBranchSwitchTarget(worktreePath, target, options) + : 'commitId' in target + ? { branch: target.commitId.toLowerCase() } + : await resolveBranchSwitchTarget(worktreePath, target, options) const { branch } = resolved - const creating = typeof target !== 'string' && target.kind === 'new' - const { refusal, previousBranch } = await readRefusal( + const creating = typeof target !== 'string' && 'kind' in target && target.kind === 'new' + const { refusal, previousBranch, alreadyOnTarget } = await readRefusal( worktreePath, branch, creating, options, - deps + deps, + commitTarget ) if (refusal !== null) { throw new BranchSwitchRefusedError(refusal) } - if (previousBranch === null && !creating) { - return { branch, previousBranch } + if (alreadyOnTarget && !creating) { + return { branch, previousBranch, head: await readBranchSwitchHead(worktreePath, options) } } let skippedPaths: string[] try { @@ -262,7 +281,20 @@ export async function switchBranchGuarded( } catch (error) { // Git can exit non-zero after HEAD already moved (a file it could not replace, a failing // post-checkout hook); that switch landed and must be recorded. - if (previousBranch === null || (await readCurrentBranch(worktreePath, options)) !== branch) { + const landedTarget = commitTarget + ? await readBranchSwitchHead(worktreePath, options) + : await readCurrentBranch(worktreePath, options) + if ( + (creating && previousBranch === branch) || + landedTarget !== branch || + (commitTarget && (await readCurrentBranch(worktreePath, options)) !== null) + ) { + if (commitTarget) { + throw new Error( + gitFailureLine(readGitCommandFailureStderr(error) ?? '') ?? + "Orca couldn't check out this commit. Check this repository in a terminal, then try again." + ) + } throw typeof target === 'string' ? error : branchSwitchFailure(error, branch) } deps.onSwitched?.() @@ -281,16 +313,18 @@ export async function switchBranchGuarded( : detail ? `Switched to ${branch}. A git hook in this repository failed afterwards and said: ${detail}. The switch itself worked.` : `Switched to ${branch}. A git hook or git step in this repository failed afterwards. The switch itself worked.`, - branch + branch, + commitTarget ? landedTarget : await readBranchSwitchHead(worktreePath, options) ) } deps.onSwitched?.() if (skippedPaths.length > 0) { throw new BranchSwitchLandedWithWarningError( filesLeftBehindMessage(branch, skippedPaths), - branch + branch, + await readBranchSwitchHead(worktreePath, options) ) } - return { branch, previousBranch } + return { branch, previousBranch, head: await readBranchSwitchHead(worktreePath, options) } }) } diff --git a/src/main/git/checkout.ts b/src/main/git/checkout.ts index c560190ef7d..1a130f9571e 100644 --- a/src/main/git/checkout.ts +++ b/src/main/git/checkout.ts @@ -19,7 +19,7 @@ export function assertValidBranchName(branch: string): void { export async function checkoutBranch( worktreePath: string, branch: string, - options: GitRuntimeOptions = {}, + options: GitRuntimeOptions & { detached?: boolean } = {}, creation?: BranchSwitchCreation ): Promise<{ skippedPaths: string[] }> { assertValidBranchName(branch) @@ -28,7 +28,7 @@ export async function checkoutBranch( ? ['checkout', '--no-track', '-b', branch, '--'] : creation?.kind === 'remote' ? ['checkout', '--track', '-b', branch, creation.startPoint, '--'] - : ['checkout', branch, '--'] + : ['checkout', ...(options.detached ? ['--detach', `${branch}^{commit}`] : [branch]), '--'] const { stderr } = await runWithGitReadCacheInvalidation(() => gitExecFileAsync(args, { ...gitOptionsForWorktree(worktreePath, options), diff --git a/src/main/git/command-runner/wsl-command-resolution-object-store.test.ts b/src/main/git/command-runner/wsl-command-resolution-object-store.test.ts index e1200a2f41a..6e4ea8cc3fb 100644 --- a/src/main/git/command-runner/wsl-command-resolution-object-store.test.ts +++ b/src/main/git/command-runner/wsl-command-resolution-object-store.test.ts @@ -34,6 +34,18 @@ describe.skipIf(originalPlatform === 'win32')('WSL Git object-store variables', Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) }) + it('applies the editor override in the guest after the login profile', async () => { + const resolved = resolveCommand('git', ['cherry-pick', '--continue'], undefined, 'Ubuntu', { + env: { GIT_EDITOR: 'true' }, + wslShellCommand: 'sh -c \'"$GIT_EDITOR"\'' + }) + const result = await runProcess({ + program: 'sh', + args: ['-c', `export GIT_EDITOR=false; ${resolved.args.at(-1)}`] + }) + expect(result.code).toBe(0) + }) + it('reach WSL Git after the login profile, appending to its alternates', async () => { await expect(alternatesSeenByWslGit(undefined)).resolves.toBe('/home/me/repo/.git/objects') await expect(alternatesSeenByWslGit('/shared/objects')).resolves.toBe( diff --git a/src/main/git/command-runner/wsl-command-resolution.ts b/src/main/git/command-runner/wsl-command-resolution.ts index 0550dd2463d..b45fd0b660c 100644 --- a/src/main/git/command-runner/wsl-command-resolution.ts +++ b/src/main/git/command-runner/wsl-command-resolution.ts @@ -115,11 +115,15 @@ export function resolveCommand( command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ${wslGitObjectStorePrefix(options.env)}` : '' + const editorPrefix = + command === 'git' && options.env?.GIT_EDITOR !== undefined + ? `GIT_EDITOR=${quotePosixShell(options.env.GIT_EDITOR)} ` + : '' const escapedCommand = options.wslShellCommand ?? quotePosixShell(command) // Quote each argument against shell splitting and expansion. const escapedArgs = translatedArgs.map(quotePosixShell) const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) - const invocation = `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` + const invocation = `${localePrefix}${editorPrefix}${escapedCommand} ${escapedArgs.join(' ')}` const enterCwd = linuxCwd ? options.cwdFailureExitCode === undefined ? `cd ${quotePosixShell(linuxCwd)} && ` @@ -140,6 +144,9 @@ export function resolveCommand( `PATH=${options.wslGitReadEnvironment.path}`, `HOME=${options.wslGitReadEnvironment.home}`, ...GIT_OUTPUT_LOCALE_ENV_ARGS, + ...(options.env?.GIT_EDITOR !== undefined + ? [`GIT_EDITOR=${options.env.GIT_EDITOR}`] + : []), ...(optionalLocks !== undefined ? [`GIT_OPTIONAL_LOCKS=${optionalLocks}`] : []), options.wslGitReadEnvironment.gitPath, ...(linuxCwd ? ['-C', linuxCwd] : []), diff --git a/src/main/git/commit-actions-real-git.test.ts b/src/main/git/commit-actions-real-git.test.ts new file mode 100644 index 00000000000..2e522388ac7 --- /dev/null +++ b/src/main/git/commit-actions-real-git.test.ts @@ -0,0 +1,361 @@ +import { createBranch } from './create-branch' +import { detectConflictOperation } from './source-control/git-conflict-operation' +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProcess } from '@orca/process-host' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installRealWorkerThreadChildProcesses } from '../worker-thread-child-process/worker-thread-child-process-real.test-fixtures' +import { applyCommit, recoverSequencer } from './commit-actions' +import type { GitCommitAction } from '../../shared/git/commands/git-commit-action' + +let directory: string +let topic: string +async function git(...args: string[]): Promise { + const result = await runProcess({ program: 'git', args, cwd: directory }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return result.stdout.trim() +} +async function commit(text: string, message: string) { + await writeFile(join(directory, 'file.txt'), text) + await git('add', 'file.txt') + await git('commit', '-qm', message) + return git('rev-parse', 'HEAD') +} +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-commit-actions-')) + await git('init', '-q') + await git('symbolic-ref', 'HEAD', 'refs/heads/main') + await git('config', 'user.name', 'Audit') + await git('config', 'user.email', 'audit@example.test') + await git('config', 'commit.gpgsign', 'false') + await git('config', 'core.hooksPath', '') + await commit('base\n', 'base') + await git('checkout', '-qb', 'topic') + topic = await commit('topic\n', 'topic change') + await git('checkout', '-q', 'main') +}) +afterEach(async () => { + vi.unstubAllEnvs() + await rm(directory, { recursive: true, force: true }) +}) + +describe('commit actions', () => { + it('creates a branch at the selected commit without switching HEAD or enabling tracking', async () => { + await git('config', 'branch.autoSetupMerge', 'always') + expect(await createBranch(directory, 'saved-topic', topic)).toEqual({ + outcome: 'landed', + branch: 'saved-topic', + commitId: topic + }) + expect(await git('rev-parse', 'saved-topic')).toBe(topic) + expect(await git('branch', '--show-current')).toBe('main') + expect(await git('for-each-ref', '--format=%(upstream)', 'refs/heads/saved-topic')).toBe('') + await expect(createBranch(directory, '@', topic)).rejects.toThrow( + 'This branch name is invalid. Choose a different name.' + ) + expect(await git('branch', '--show-current')).toBe('main') + }) + + it('cherry-picks a root commit onto a clean unborn branch', async () => { + const root = await git('rev-list', '--max-parents=0', 'HEAD') + await git('checkout', '--orphan', 'unborn') + await git('rm', '-rf', '.') + expect(await applyCommit(directory, 'cherry-pick', root)).toMatchObject({ + outcome: 'landed', + branch: 'unborn' + }) + expect(await git('show', 'HEAD:file.txt')).toBe('base') + expect(await git('log', '-1', '--format=%s')).toBe('base') + }) + it('cherry-picks onto the current branch without opening an editor', async () => { + const result = await applyCommit(directory, 'cherry-pick', topic) + expect(result).toMatchObject({ outcome: 'landed', branch: 'main' }) + expect(await git('show', 'HEAD:file.txt')).toBe('topic') + expect(await git('log', '-1', '--format=%s')).toBe('topic change') + }) + it('reverts a commit without opening an editor', async () => { + await git('cherry-pick', topic) + const result = await applyCommit(directory, 'revert', await git('rev-parse', 'HEAD')) + expect(result).toMatchObject({ outcome: 'landed', branch: 'main' }) + expect(await git('show', 'HEAD:file.txt')).toBe('base') + expect(await git('log', '-1', '--format=%s')).toBe('Revert "topic change"') + }) + it('skips an empty graph cherry-pick while preserving unrelated unstaged edits', async () => { + await writeFile(join(directory, 'unrelated.txt'), 'original\n') + await git('add', 'unrelated.txt') + await git('commit', '-qm', 'unrelated file') + await git('cherry-pick', topic) + await writeFile(join(directory, 'unrelated.txt'), 'work in progress\n') + expect(await applyCommit(directory, 'cherry-pick', topic)).toEqual({ + outcome: 'empty', + message: 'Already applied, nothing to cherry-pick' + }) + await expect(git('rev-parse', '--verify', 'CHERRY_PICK_HEAD')).rejects.toThrow() + expect(await git('log', '--format=%s')).toBe('topic change\nunrelated file\nbase') + expect(await git('show', 'HEAD:file.txt')).toBe('topic') + expect(await git('diff', '--', 'unrelated.txt')).toContain('+work in progress') + expect(await git('status', '--porcelain')).toBe('M unrelated.txt') + }) + it.each([ + ['cherry-pick', 'Already applied, nothing to cherry-pick', 'CHERRY_PICK_HEAD'], + ['revert', 'Nothing to revert; the changes are already undone.', 'REVERT_HEAD'] + ] as const)( + 'finishes an empty %s conflict resolution', + async (operation, message, operationHead) => { + await commit('different\n', 'diverged') + expect(await applyCommit(directory, operation, topic)).toEqual({ outcome: 'conflicted' }) + await writeFile(join(directory, 'file.txt'), 'different\n') + await git('add', 'file.txt') + if (operation === 'revert') { + await writeFile(join(directory, 'untracked.txt'), 'keep me\n') + } + expect(await recoverSequencer(directory, operation, 'continue')).toEqual({ + outcome: 'empty', + message + }) + expect(await detectConflictOperation(directory)).toBe('unknown') + await expect(git('rev-parse', '--verify', operationHead)).rejects.toThrow() + expect(await git('log', '-1', '--format=%s')).toBe('diverged') + expect(await git('status', '--porcelain')).toBe( + operation === 'revert' ? '?? untracked.txt' : '' + ) + } + ) + it('skips an empty final cherry-pick while preserving earlier picks and unrelated unstaged edits', async () => { + await git('checkout', '-q', 'topic') + await writeFile(join(directory, 'first.txt'), 'first\n') + await git('add', 'first.txt') + await git('commit', '-qm', 'first pick') + const first = await git('rev-parse', 'HEAD') + await writeFile(join(directory, 'second.txt'), 'second\n') + await git('add', 'second.txt') + await git('commit', '-qm', 'second pick') + const last = await commit('last\n', 'last pick') + await git('checkout', '-q', 'main') + await writeFile(join(directory, 'unrelated.txt'), 'original\n') + await git('add', 'unrelated.txt') + await commit('different\n', 'diverged') + await expect(git('cherry-pick', `${first}^..${last}`)).rejects.toThrow() + await writeFile(join(directory, 'file.txt'), 'different\n') + await git('add', 'file.txt') + await writeFile(join(directory, 'unrelated.txt'), 'work in progress\n') + + expect(await recoverSequencer(directory, 'cherry-pick', 'continue')).toEqual({ + outcome: 'empty', + message: 'Already applied, nothing to cherry-pick' + }) + expect(await git('log', '--format=%s')).toBe('second pick\nfirst pick\ndiverged\nbase') + expect(await git('show', 'HEAD:first.txt')).toBe('first') + expect(await git('show', 'HEAD:second.txt')).toBe('second') + expect(await git('show', 'HEAD:file.txt')).toBe('different') + await expect(git('rev-parse', '--verify', 'CHERRY_PICK_HEAD')).rejects.toThrow() + expect(await git('diff', '--', 'unrelated.txt')).toContain('+work in progress') + expect(await git('status', '--porcelain')).toBe('M unrelated.txt') + }) + it('uses the recorded merge mainline without blocking unrelated work from the other parent', async () => { + await git('merge', '--no-ff', '-s', 'ours', '--no-edit', 'topic') + const merge = await git('rev-parse', 'HEAD') + const base = await git('rev-parse', 'HEAD^1') + await git('checkout', '-qb', 'pick-source', base) + await writeFile(join(directory, 'picked.txt'), 'picked\n') + await git('add', 'picked.txt') + await git('commit', '-qm', 'first pick') + const first = await git('rev-parse', 'HEAD') + await git('checkout', '-qb', 'destination', base) + await writeFile(join(directory, 'file.txt'), 'work in progress\n') + await expect(git('cherry-pick', '-m', '1', first, merge)).rejects.toThrow() + + expect(await recoverSequencer(directory, 'cherry-pick', 'continue')).toEqual({ + outcome: 'empty', + message: 'Already applied, nothing to cherry-pick' + }) + expect(await git('log', '--format=%s')).toBe('first pick\nbase') + expect(await git('show', 'HEAD:picked.txt')).toBe('picked') + expect(await git('diff', '--', 'file.txt')).toContain('+work in progress') + expect(await git('status', '--porcelain')).toBe('M file.txt') + await expect(git('rev-parse', '--verify', 'CHERRY_PICK_HEAD')).rejects.toThrow() + expect(await detectConflictOperation(directory)).toBe('unknown') + }) + it('keeps a conflicting revert whose subject contains nothing to commit in progress', async () => { + await git('checkout', '-q', 'topic') + await git('commit', '--amend', '-qm', 'Fix nothing to commit warning') + topic = await git('rev-parse', 'HEAD') + await git('checkout', '-q', 'main') + await commit('different\n', 'diverged') + + expect(await applyCommit(directory, 'revert', topic)).toEqual({ outcome: 'conflicted' }) + await expect(recoverSequencer(directory, 'revert', 'continue')).rejects.toThrow(/^error: /) + expect(await git('rev-parse', '--verify', 'REVERT_HEAD')).toBe(topic) + expect(await git('diff', '--name-only', '--diff-filter=U')).toBe('file.txt') + expect(await git('log', '-1', '--format=%s')).toBe('diverged') + }) + it('preserves a staged gitlink when Continue fails with submodule diffs configured off', async () => { + await git('-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', directory, 'module') + await git('commit', '-qm', 'add module') + await git('checkout', '-qb', 'module-topic') + await git('-C', 'module', 'checkout', '-q', topic) + await git('add', 'module') + await git('commit', '-qm', 'topic module link') + const target = await git('rev-parse', 'HEAD') + await git('checkout', '-q', 'main') + await git('-C', 'module', 'checkout', '-qb', 'other-link', 'origin/main') + await writeFile(join(directory, 'module', 'file.txt'), 'other module\n') + await git( + '-C', + 'module', + '-c', + 'user.name=Audit', + '-c', + 'user.email=audit@example.test', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=', + 'commit', + '-qam', + 'other module' + ) + await git('add', 'module') + await git('commit', '-qm', 'other module link') + expect(await applyCommit(directory, 'cherry-pick', target)).toEqual({ outcome: 'conflicted' }) + await git('-C', 'module', 'checkout', '-q', topic) + await git('add', 'module') + await git('config', 'diff.ignoreSubmodules', 'all') + const hooks = join(directory, '.git', 'hooks') + await git('config', 'core.hooksPath', hooks) + await writeFile( + join(hooks, 'pre-commit'), + '#!/bin/sh\necho "error: reject gitlink" >&2\nexit 1\n' + ) + await chmod(join(hooks, 'pre-commit'), 0o755) + + await expect(recoverSequencer(directory, 'cherry-pick', 'continue')).rejects.toThrow( + 'error: reject gitlink' + ) + expect(await git('rev-parse', '--verify', 'CHERRY_PICK_HEAD')).toBe(target) + expect(await git('diff', '--cached', '--name-only', '--ignore-submodules=none')).toBe('module') + expect(await git('rev-parse', ':module')).toBe(topic) + expect(await git('log', '-1', '--format=%s')).toBe('other module link') + }) + it('keeps an unstaged resolution in progress instead of reporting an empty cherry-pick', async () => { + await commit('different\n', 'diverged') + expect(await applyCommit(directory, 'cherry-pick', topic)).toEqual({ outcome: 'conflicted' }) + await writeFile(join(directory, 'file.txt'), 'resolved\n') + await git('add', 'file.txt') + await git('restore', '--staged', 'file.txt') + + await expect(recoverSequencer(directory, 'cherry-pick', 'continue')).rejects.toThrow() + expect(await git('rev-parse', '--verify', 'CHERRY_PICK_HEAD')).toBe(topic) + expect(await git('diff', '--', 'file.txt')).toContain('+resolved') + expect(await git('status', '--porcelain')).toBe('M file.txt') + expect(await git('log', '-1', '--format=%s')).toBe('diverged') + }) + for (const inProgress of ['merge', 'rebase', 'cherry-pick', 'revert', 'stash'] as const) { + it(`refuses both commit actions during an existing ${inProgress} conflict`, async () => { + if (inProgress === 'stash') { + await writeFile(join(directory, 'file.txt'), 'stashed\n') + await git('stash', 'push', '-m', 'saved changes') + } + await commit('different\n', 'diverged') + await expect( + inProgress === 'stash' ? git('stash', 'pop') : git(inProgress, topic) + ).rejects.toThrow() + for (const operation of ['cherry-pick', 'revert'] as const) { + expect(await applyCommit(directory, operation, topic)).toEqual({ + outcome: 'refused', + message: + inProgress === 'stash' + ? 'Git did not create a commit. Check the checkout and try again.' + : `Finish or abort the ${inProgress} in progress before trying again.` + }) + } + expect(await detectConflictOperation(directory)).toBe( + inProgress === 'stash' ? 'unknown' : inProgress + ) + expect(await git('diff', '--name-only', '--diff-filter=U')).toBe('file.txt') + }) + } + for (const operation of ['cherry-pick', 'revert'] as const satisfies readonly GitCommitAction[]) { + it(`${operation} leaves a dirty checkout untouched`, async () => { + if (operation === 'revert') { + await git('cherry-pick', topic) + } + await writeFile(join(directory, 'file.txt'), 'uncommitted\n') + const result = await applyCommit(directory, operation, topic) + expect(result).toEqual({ + outcome: 'refused', + message: 'Commit or stash your changes, then try again.' + }) + expect(await git('diff', '--', 'file.txt')).toContain('+uncommitted') + expect(await git('log', '-1', '--format=%s')).toBe( + operation === 'revert' ? 'topic change' : 'base' + ) + }) + it(`${operation} can be aborted after a conflict, rejecting the wrong operation`, async () => { + await commit('different\n', 'diverged') + expect(await applyCommit(directory, operation, topic)).toEqual({ outcome: 'conflicted' }) + expect(await git('diff', '--name-only', '--diff-filter=U')).toBe('file.txt') + expect(await detectConflictOperation(directory)).toBe(operation) + await expect( + recoverSequencer(directory, operation === 'revert' ? 'cherry-pick' : 'revert', 'abort') + ).rejects.toThrow('in progress') + await expect(recoverSequencer(directory, operation, 'continue')).rejects.toThrow(/^error: /) + await recoverSequencer(directory, operation, 'abort') + expect(await detectConflictOperation(directory)).toBe('unknown') + expect(await git('show', 'HEAD:file.txt')).toBe('different') + expect(await git('status', '--porcelain')).toBe('') + }) + it(`${operation} continues with identity only in HOME and a failing configured editor`, async () => { + await commit('different\n', 'diverged') + await applyCommit(directory, operation, topic) + await writeFile(join(directory, 'file.txt'), 'resolved\n') + await git('add', 'file.txt') + const home = join(directory, '.git', 'test-home') + await mkdir(home) + await writeFile( + join(home, '.gitconfig'), + '[user]\nname = Home Identity\nemail = home@example.test\n' + ) + await git('config', '--unset', 'user.name') + await git('config', '--unset', 'user.email') + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', home) + vi.stubEnv('XDG_CONFIG_HOME', home) + vi.stubEnv('GIT_CONFIG_GLOBAL', undefined) + vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') + vi.stubEnv('GIT_EDITOR', 'false') + for (const key of [ + 'GIT_AUTHOR_NAME', + 'GIT_AUTHOR_EMAIL', + 'GIT_COMMITTER_NAME', + 'GIT_COMMITTER_EMAIL' + ]) { + vi.stubEnv(key, undefined) + } + await recoverSequencer(directory, operation, 'continue') + expect(await detectConflictOperation(directory)).toBe('unknown') + expect(await git('show', 'HEAD:file.txt')).toBe('resolved') + expect(await git('status', '--porcelain')).toBe('') + expect(await git('log', '-1', '--format=%cn <%ce>')).toBe('Home Identity ') + }) + it(`${operation} refuses a merge commit using its actual parents`, async () => { + await git('checkout', '-qb', 'other') + await writeFile(join(directory, 'other.txt'), 'other\n') + await git('add', 'other.txt') + await git('commit', '-qm', 'other') + await git('checkout', '-q', 'topic') + await git('merge', '--no-ff', '--no-edit', 'other') + const merge = await git('rev-parse', 'HEAD') + await git('checkout', '-q', 'main') + expect(await applyCommit(directory, operation, merge)).toEqual({ + outcome: 'refused', + message: `Can't ${operation} a merge commit from here` + }) + expect(await git('log', '-1', '--format=%s')).toBe('base') + }) + } +}) +installRealWorkerThreadChildProcesses() diff --git a/src/main/git/commit-actions.ts b/src/main/git/commit-actions.ts new file mode 100644 index 00000000000..3639a71a7c9 --- /dev/null +++ b/src/main/git/commit-actions.ts @@ -0,0 +1,229 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { detectConflictOperation } from './source-control/git-conflict-operation' +import type { + GitCommitAction, + GitCommitActionResult +} from '../../shared/git/commands/git-commit-action' +import { readGitCommandFailureStderr } from '../../shared/git/commands/git-command-failure-text' +import { gitFailureLine, withCheckoutLock } from './branch-switch' +import { gitOptionsForWorktree, type GitRuntimeOptions } from './git-runtime-options' +import { gitExecFileAsync } from './runner' +import { runWithGitReadCacheInvalidation } from './source-control/git-read-cache-invalidation' +import { bulkPathspecCommands } from './source-control/git-pathspec' +import { resolveGitDir } from './source-control/resolve-git-dir' + +function throwSequencerFailure(error: unknown): never { + throw new Error( + gitFailureLine(readGitCommandFailureStderr(error) ?? '') ?? + 'Could not finish the operation. Refresh Source Control and try again.' + ) +} + +async function commitActionDiffRefs( + operationHead: string, + execution: ReturnType +): Promise { + const { stdout } = await gitExecFileAsync( + ['show', '--no-patch', '--format=%P', operationHead, '--'], + execution + ) + const parents = stdout.trim().split(/\s+/).filter(Boolean) + if (parents.length > 1) { + try { + const gitDir = await resolveGitDir(execution.cwd, execution) + const opts = await readFile(join(gitDir, 'sequencer', 'opts'), 'utf8') + const { stdout: mainline } = await gitExecFileAsync( + ['config', '--file', '-', '--int', '--get', 'options.mainline'], + { ...execution, stdin: opts } + ) + const parent = parents[Number(mainline.trim()) - 1] + if (parent) { + return [parent, operationHead] + } + } catch {} + // Single-item operations may omit opts; the union protects every possible resolution. + } + return [operationHead] +} + +async function finishEmptyCommitAction( + operation: GitCommitAction, + execution: ReturnType, + action: 'apply' | 'continue' +): Promise { + try { + if ((await detectConflictOperation(execution.cwd, execution)) !== operation) { + return undefined + } + const { stdout: unmerged } = await gitExecFileAsync(['ls-files', '-u'], execution) + if (unmerged.trim()) { + return undefined + } + await gitExecFileAsync( + ['diff-index', '--cached', '--quiet', '--ignore-submodules=none', 'HEAD', '--'], + execution + ) + if (action === 'continue') { + const refs = await commitActionDiffRefs( + operation === 'cherry-pick' ? 'CHERRY_PICK_HEAD' : 'REVERT_HEAD', + execution + ) + const { stdout: paths } = await gitExecFileAsync( + [ + 'diff-tree', + '--no-commit-id', + '--name-only', + '-r', + '-z', + '--root', + '-m', + '--no-renames', + '--ignore-submodules=none', + ...refs, + '--' + ], + execution + ) + for (const args of bulkPathspecCommands( + ['diff-files', '--quiet', '--ignore-submodules=none', '--'], + paths.split('\0').filter(Boolean), + execution.cwd, + execution, + 'git' + )) { + await gitExecFileAsync(args, execution) + } + } + } catch { + return undefined + } + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync([operation, '--skip'], { + ...execution, + env: { ...process.env, GIT_EDITOR: 'true' } + }) + ) + return { + outcome: 'empty', + message: + operation === 'revert' + ? 'Nothing to revert; the changes are already undone.' + : 'Already applied, nothing to cherry-pick' + } +} + +export async function applyCommit( + worktreePath: string, + operation: GitCommitAction, + commitId: string, + options: GitRuntimeOptions = {} +): Promise { + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(commitId)) { + throw new Error('Expected a full git object id') + } + return withCheckoutLock(worktreePath, async () => { + const execution = gitOptionsForWorktree(worktreePath, options) + const inProgress = await detectConflictOperation(worktreePath, options) + if (inProgress !== 'unknown') { + return { + outcome: 'refused', + message: `Finish or abort the ${inProgress} in progress before trying again.` + } + } + const { stdout: parents } = await gitExecFileAsync( + ['show', '--no-patch', '--format=%P', commitId, '--'], + execution + ) + if (parents.trim().split(/\s+/).filter(Boolean).length > 1) { + return { outcome: 'refused', message: `Can't ${operation} a merge commit from here` } + } + const readHead = async () => + gitExecFileAsync(['rev-parse', '--verify', '--quiet', 'HEAD'], execution) + .then(({ stdout }) => stdout.trim()) + .catch(() => null) + const before = await readHead() + let failure: unknown + try { + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync([operation, '--no-edit', commitId], execution) + ) + } catch (error) { + failure = error + } + const commit = await readHead() + const { stdout: branch } = await gitExecFileAsync(['branch', '--show-current'], execution) + if (commit && commit !== before) { + return { + outcome: failure ? 'landed-with-warning' : 'landed', + commitId: commit, + branch: branch.trim(), + ...(failure + ? { + message: + readGitCommandFailureStderr(failure) ?? + 'Git reported an error after creating the commit.' + } + : {}) + } + } + const stderr = readGitCommandFailureStderr(failure) ?? '' + const empty = failure + ? await finishEmptyCommitAction(operation, execution, 'apply').catch(throwSequencerFailure) + : undefined + if (empty) { + return empty + } + const { stdout: conflicts } = await gitExecFileAsync( + ['diff', '--name-only', '--diff-filter=U'], + execution + ) + if (conflicts.trim() && (await detectConflictOperation(worktreePath, options)) === operation) { + return { outcome: 'conflicted' } + } + return { + outcome: 'refused', + message: /would be overwritten|local changes.*would|commit your changes or stash/i.test( + stderr + ) + ? 'Commit or stash your changes, then try again.' + : 'Git did not create a commit. Check the checkout and try again.' + } + }) +} + +export async function recoverSequencer( + worktreePath: string, + operation: GitCommitAction, + action: 'abort' | 'continue', + options: GitRuntimeOptions = {} +): Promise { + if (operation !== 'cherry-pick' && operation !== 'revert') { + throw new Error('Expected cherry-pick or revert') + } + return withCheckoutLock(worktreePath, async () => { + const inProgress = await detectConflictOperation(worktreePath, options).catch( + throwSequencerFailure + ) + if (inProgress !== operation) { + throw new Error(`There is no ${operation} in progress. Refresh Source Control and try again.`) + } + const execution = gitOptionsForWorktree(worktreePath, options) + return runWithGitReadCacheInvalidation(() => + gitExecFileAsync([operation, `--${action}`], { + ...execution, + ...(action === 'continue' ? { env: { ...process.env, GIT_EDITOR: 'true' } } : {}) + }) + ) + .then(() => undefined) + .catch(async (error: unknown) => { + const empty = + action === 'continue' && (await finishEmptyCommitAction(operation, execution, action)) + if (empty) { + return empty + } + throw error + }) + .catch(throwSequencerFailure) + }) +} diff --git a/src/main/git/create-branch.ts b/src/main/git/create-branch.ts new file mode 100644 index 00000000000..ea4f3893463 --- /dev/null +++ b/src/main/git/create-branch.ts @@ -0,0 +1,33 @@ +import { BranchSwitchCollisionError, validateNewBranchName } from './branch-switch-target' +import { readGitCommandFailureStderr } from '../../shared/git/commands/git-command-failure-text' +import type { GitCommitActionResult } from '../../shared/git/commands/git-commit-action' +import { withCheckoutLock } from './branch-switch' +import { gitOptionsForWorktree, type GitRuntimeOptions } from './git-runtime-options' +import { gitExecFileAsync } from './runner' +import { runWithGitReadCacheInvalidation } from './source-control/git-read-cache-invalidation' + +export async function createBranch( + worktreePath: string, + name: string, + startPoint: string, + options: GitRuntimeOptions = {} +): Promise { + return withCheckoutLock(worktreePath, async () => { + await validateNewBranchName(worktreePath, name, options) + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(startPoint)) { + throw new Error('Expected a full git object id') + } + const execution = gitOptionsForWorktree(worktreePath, options) + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync(['branch', '--no-track', '--', name, `${startPoint}^{commit}`], execution) + ).catch((error) => { + if (/already exists/.test(readGitCommandFailureStderr(error) ?? '')) { + throw new BranchSwitchCollisionError(name) + } + throw new Error( + "Orca couldn't create the branch. Check this repository in a terminal, then try again." + ) + }) + return { outcome: 'landed', branch: name, commitId: startPoint.toLowerCase() } + }) +} diff --git a/src/main/git/source-control/git-conflict-operation.ts b/src/main/git/source-control/git-conflict-operation.ts index 9652a8bf21a..dde06f08a52 100644 --- a/src/main/git/source-control/git-conflict-operation.ts +++ b/src/main/git/source-control/git-conflict-operation.ts @@ -15,13 +15,15 @@ export async function detectConflictOperation( const gitDir = await resolveGitDir(worktreePath, options) const mergeHead = path.join(gitDir, 'MERGE_HEAD') const cherryPickHead = path.join(gitDir, 'CHERRY_PICK_HEAD') + const revertHead = path.join(gitDir, 'REVERT_HEAD') const rebaseMergeDir = path.join(gitDir, 'rebase-merge') const rebaseApplyDir = path.join(gitDir, 'rebase-apply') // Probe concurrently without blocking main: each WSL/UNC path can cost a 9p round trip. - const [hasMergeHead, hasCherryPickHead, hasRebaseMergeDir, hasRebaseApplyDir] = await Promise.all( - [mergeHead, cherryPickHead, rebaseMergeDir, rebaseApplyDir].map(pathExists) - ) + const [hasMergeHead, hasCherryPickHead, hasRebaseMergeDir, hasRebaseApplyDir, hasRevertHead] = + await Promise.all( + [mergeHead, cherryPickHead, rebaseMergeDir, rebaseApplyDir, revertHead].map(pathExists) + ) const hasRebaseDir = hasRebaseMergeDir || hasRebaseApplyDir if (hasMergeHead) { @@ -33,6 +35,9 @@ export async function detectConflictOperation( if (hasCherryPickHead) { return 'cherry-pick' } + if (hasRevertHead) { + return 'revert' + } return 'unknown' } diff --git a/src/main/git/source-control/git-pathspec.test.ts b/src/main/git/source-control/git-pathspec.test.ts new file mode 100644 index 00000000000..612d75a3f61 --- /dev/null +++ b/src/main/git/source-control/git-pathspec.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from 'vitest' +import { bulkPathspecCommands } from './git-pathspec' + +it.skipIf(process.platform === 'win32')( + 'preserves literal backslashes in Git-reported WSL paths', + () => { + expect( + bulkPathspecCommands( + ['diff-files', '--quiet', '--'], + [String.raw`literal\name.txt`], + '/tmp/repo', + { wslDistro: 'Ubuntu' }, + 'git' + ) + ).toEqual([['diff-files', '--quiet', '--', String.raw`:(literal)literal\name.txt`]]) + } +) diff --git a/src/main/git/source-control/git-pathspec.ts b/src/main/git/source-control/git-pathspec.ts index c34c5c6abee..6184ad02ce7 100644 --- a/src/main/git/source-control/git-pathspec.ts +++ b/src/main/git/source-control/git-pathspec.ts @@ -65,7 +65,8 @@ export function bulkPathspecCommands( leadingArgs: readonly string[], filePaths: readonly string[], worktreePath: string, - options: GitRuntimeOptions + options: GitRuntimeOptions, + pathSource: 'host' | 'git' = 'host' ): string[][] { // Budget belongs to the host that spawns; the overhead measured above belongs to the transport. const budget = process.platform === 'win32' ? MAX_COMMAND_LINE_CHARS : POSIX_COMMAND_LINE_BUDGET @@ -74,7 +75,10 @@ export function bulkPathspecCommands( let pathspecs: string[] = [] let length = baseLength for (const filePath of filePaths) { - const pathspec = literalPathspec(filePath, worktreePath, options) + const pathspec = + pathSource === 'git' + ? `:(literal)${filePath}` + : literalPathspec(filePath, worktreePath, options) const cost = finishedCommandLineLength([...leadingArgs, pathspec], worktreePath, options) - baseLength if (pathspecs.length > 0 && (pathspecs.length >= BULK_CHUNK_SIZE || length + cost > budget)) { diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 6f4392173e2..681f6972a1f 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -1,3 +1,4 @@ +import { GIT_REVERT_CONFLICT_CLIENT_CAPABILITY } from '../../shared/git/commands/git-commit-action' import { GIT_GRAPH_TAB_CAPABILITY } from '../../shared/source-control/runtime/git-graph-capabilities' import { REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, @@ -17,6 +18,7 @@ import { /** Curate one capability set for unary/streaming desktop calls; remote-only gates can alter local semantics even on the same build. */ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ GIT_GRAPH_TAB_CAPABILITY, + GIT_REVERT_CONFLICT_CLIENT_CAPABILITY, ...STRUCTURED_AGENT_SESSION_RENDERER_CLIENT_CAPABILITIES, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, // Agent-launch authorization requires this even for the same-build desktop renderer. diff --git a/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts index c805fe4a7a5..4db932e8e80 100644 --- a/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts @@ -1,3 +1,6 @@ +import type { GitCommitAction } from '../../../shared/git/commands/git-commit-action' +import { applyCommit, recoverSequencer } from '../../git/commit-actions' +import { createBranch } from '../../git/create-branch' import { ipcMain } from 'electron' import { commitChanges, completeMerge } from '../../git/source-control/commit-changes' import { resolveRegisteredWorktreePath } from '../worktrees/removal/registered-worktree-roots-cache' @@ -8,6 +11,63 @@ import { getConnectionExecutionHostId } from '../../../shared/execution-host' export function registerFilesystemGitCommitHandlers(context: FilesystemHandlerContext): void { const { store } = context + ipcMain.handle( + 'git:createBranch', + async ( + _event, + args: { worktreePath: string; name: string; startPoint: string; connectionId?: string } + ) => { + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return createBranch(worktreePath, args.name, args.startPoint, { + ...getLocalGitOptionsForRegisteredWorktree(store, args.worktreePath, worktreePath), + admissionTier: 'interactive' + }) + } + ) + for (const action of ['abort', 'continue'] as const) { + const method = action === 'abort' ? 'sequencerAbort' : 'sequencerContinue' + ipcMain.handle( + `git:${method}`, + async ( + _event, + args: { worktreePath: string; operation: GitCommitAction; connectionId?: string } + ) => { + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return recoverSequencer(worktreePath, args.operation, action, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + } + for (const [method, operation] of [ + ['cherryPick', 'cherry-pick'], + ['revert', 'revert'] + ] as const) { + ipcMain.handle( + `git:${method}`, + async (_event, args: { worktreePath: string; commitId: string; connectionId?: string }) => { + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return applyCommit(worktreePath, operation, args.commitId, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + } ipcMain.handle( 'git:commit', async ( diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts index d1153c69506..282992fb7de 100644 --- a/src/main/providers/git-provider-contract.ts +++ b/src/main/providers/git-provider-contract.ts @@ -1,3 +1,7 @@ +import type { + GitCommitAction, + GitCommitActionResult +} from '../../shared/git/commands/git-commit-action' import type { GitBranchCompareResult, GitCommitCompareResult, @@ -41,6 +45,21 @@ export type IGitProvider = { getHistoryRefs(worktreePath: string, query?: string): Promise getHistory(worktreePath: string, options?: GitHistoryOptions): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> + createBranch( + worktreePath: string, + name: string, + startPoint: string + ): Promise + applyCommit( + worktreePath: string, + operation: GitCommitAction, + commitId: string + ): Promise + recoverSequencer( + worktreePath: string, + operation: GitCommitAction, + action: 'abort' | 'continue' + ): Promise completeMerge(worktreePath: string): Promise<{ success: boolean; error?: string }> getStagedCommitContext(worktreePath: string): Promise getDiff( @@ -72,7 +91,8 @@ export type IGitProvider = { checkoutBranch( worktreePath: string, branch: string, - creation?: BranchSwitchCreation + creation?: BranchSwitchCreation, + detached?: boolean ): Promise<{ skippedPaths: string[] }> listLocalBranches(worktreePath: string): Promise getBranchCompare( diff --git a/src/main/providers/local-git-provider.ts b/src/main/providers/local-git-provider.ts index 11017f9bd90..607fd585333 100644 --- a/src/main/providers/local-git-provider.ts +++ b/src/main/providers/local-git-provider.ts @@ -1,3 +1,5 @@ +import { createBranch } from '../git/create-branch' +import { applyCommit, recoverSequencer } from '../git/commit-actions' import { gitSyncForkDefaultBranch } from '../git/fork-sync' import type { GitRuntimeOptions } from '../git/git-runtime-options' import { checkIgnoredPaths } from '../git/source-control/check-ignored-paths' @@ -75,6 +77,12 @@ export function createLocalGitProvider(options: LocalGitProviderOptions = {}): L getHistory: (worktreePath, historyOptions = {}) => getHistory(worktreePath, { ...historyOptions, ...interactive }), commit: (worktreePath, message) => commitChanges(worktreePath, message, interactive), + createBranch: (worktreePath, name, startPoint) => + createBranch(worktreePath, name, startPoint, interactive), + applyCommit: (worktreePath, operation, commitId) => + applyCommit(worktreePath, operation, commitId, interactive), + recoverSequencer: (worktreePath, operation, action) => + recoverSequencer(worktreePath, operation, action, interactive), completeMerge: (worktreePath) => completeMerge(worktreePath, interactive), getStagedCommitContext: (worktreePath) => getStagedCommitContext(worktreePath, interactive), getDiff: (worktreePath, filePath, staged, compareAgainstHead, oldPath) => @@ -104,10 +112,13 @@ export function createLocalGitProvider(options: LocalGitProviderOptions = {}): L detectConflictOperation: (worktreePath) => detectConflictOperation(worktreePath, base), abortMerge: (worktreePath) => abortMerge(worktreePath, interactive), abortRebase: (worktreePath) => abortRebase(worktreePath, interactive), - checkoutBranch: (worktreePath, branch, creation) => - creation - ? checkoutBranch(worktreePath, branch, interactive, creation) - : checkoutBranch(worktreePath, branch, interactive), + checkoutBranch: (worktreePath, branch, creation, detached) => + checkoutBranch( + worktreePath, + branch, + { ...interactive, ...(detached ? { detached } : {}) }, + creation + ), listLocalBranches: (worktreePath) => listLocalBranches(worktreePath, base), getBranchCompare: (worktreePath, baseRef, compareOptions) => getBranchCompare(worktreePath, baseRef, { diff --git a/src/main/runtime/integrations/git/orca-runtime-git.ts b/src/main/runtime/integrations/git/orca-runtime-git.ts index 08a0cfb407a..51576ad17d0 100644 --- a/src/main/runtime/integrations/git/orca-runtime-git.ts +++ b/src/main/runtime/integrations/git/orca-runtime-git.ts @@ -33,6 +33,11 @@ export class RuntimeGitCommands { readonly rebaseRuntimeGitFromBase: RuntimeGitSyncCommands['rebaseRuntimeGitFromBase'] readonly pushRuntimeGit: RuntimeGitSyncCommands['pushRuntimeGit'] readonly commitRuntimeGit: RuntimeGitSyncCommands['commitRuntimeGit'] + readonly createRuntimeGitBranch: RuntimeGitSyncCommands['createRuntimeGitBranch'] + readonly cherryPickRuntimeGit: RuntimeGitSyncCommands['cherryPickRuntimeGit'] + readonly revertRuntimeGit: RuntimeGitSyncCommands['revertRuntimeGit'] + readonly sequencerAbortRuntimeGit: RuntimeGitSyncCommands['sequencerAbortRuntimeGit'] + readonly sequencerContinueRuntimeGit: RuntimeGitSyncCommands['sequencerContinueRuntimeGit'] readonly completeRuntimeGitMerge: RuntimeGitSyncCommands['completeRuntimeGitMerge'] readonly generateRuntimeCommitMessage: RuntimeGitGenerationCommands['generateRuntimeCommitMessage'] readonly cancelRuntimeGenerateCommitMessage: RuntimeGitGenerationCommands['cancelRuntimeGenerateCommitMessage'] @@ -81,6 +86,11 @@ export class RuntimeGitCommands { this.rebaseRuntimeGitFromBase = sync.rebaseRuntimeGitFromBase.bind(sync) this.pushRuntimeGit = sync.pushRuntimeGit.bind(sync) this.commitRuntimeGit = sync.commitRuntimeGit.bind(sync) + this.createRuntimeGitBranch = sync.createRuntimeGitBranch.bind(sync) + this.cherryPickRuntimeGit = sync.cherryPickRuntimeGit.bind(sync) + this.revertRuntimeGit = sync.revertRuntimeGit.bind(sync) + this.sequencerAbortRuntimeGit = sync.sequencerAbortRuntimeGit.bind(sync) + this.sequencerContinueRuntimeGit = sync.sequencerContinueRuntimeGit.bind(sync) this.completeRuntimeGitMerge = sync.completeRuntimeGitMerge.bind(sync) this.generateRuntimeCommitMessage = generation.generateRuntimeCommitMessage.bind(generation) this.cancelRuntimeGenerateCommitMessage = diff --git a/src/main/runtime/integrations/git/runtime-branch-switch-host.test.ts b/src/main/runtime/integrations/git/runtime-branch-switch-host.test.ts new file mode 100644 index 00000000000..c7b5040da27 --- /dev/null +++ b/src/main/runtime/integrations/git/runtime-branch-switch-host.test.ts @@ -0,0 +1,36 @@ +import { expect, it, vi } from 'vitest' +import { createBranchSwitchHost } from './runtime-branch-switch-host' +import type { RuntimeStore } from '../../construction/runtime-store-contract' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' + +vi.mock('../../../persistence/host-qualified-worktree-meta', () => ({ + worktreeRowTarget: (_store: unknown, worktree: unknown) => worktree +})) + +it('keeps automatic naming after commit checkout while preserving branch-link clearing', () => { + const meta = { linkedPR: 42, branchNameIsUserChosen: false } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the switch callback only reads and updates this metadata fixture. + const store = { + getWorktreeMetaForHost: () => meta, + setWorktreeMeta: (_target: unknown, updates: Partial) => + Object.assign(meta, updates) + } as unknown as RuntimeStore + const host = createBranchSwitchHost({ + getStore: () => store, + readAgentRows: () => [], + readAgentPaneRows: () => [], + ptys: () => [], + paneOf: () => ({ tabId: null, paneKey: null }), + terminalHandlesOf: () => [], + hasSyncedTab: () => false, + savedTabIds: () => [], + probeProcess: async () => null, + notifyBranchChanged: () => {}, + folderWorkspacePaths: () => new Map() + }) + const worktree = { id: 'r::/repo', hostId: 'local' as const, repoId: 'r' } + host.recordBranchSwitched(worktree, true) + expect(meta).toMatchObject({ linkedPR: null, branchNameIsUserChosen: false }) + host.recordBranchSwitched(worktree) + expect(meta.branchNameIsUserChosen).toBe(true) +}) diff --git a/src/main/runtime/integrations/git/runtime-branch-switch-host.ts b/src/main/runtime/integrations/git/runtime-branch-switch-host.ts index b949f991631..65258fc6308 100644 --- a/src/main/runtime/integrations/git/runtime-branch-switch-host.ts +++ b/src/main/runtime/integrations/git/runtime-branch-switch-host.ts @@ -129,7 +129,10 @@ export function createBranchSwitchHost

( }, // Watchers only cover desktop-hosted local repos, so Orca's own switch updates every client // itself; the user's choice also ends first-work automatic branch naming. - recordBranchSwitched: (worktree: Pick) => { + recordBranchSwitched: ( + worktree: Pick, + detached = false + ) => { const store = sources.getStore() try { const target = store ? worktreeRowTarget(store, worktree) : undefined @@ -146,7 +149,7 @@ export function createBranchSwitchHost

( meta.pushTarget !== undefined) store.setWorktreeMeta(target, { ...(hasBranchScopedLinks ? CLEARED_HOSTED_REVIEW_LINK_UPDATES : {}), - branchNameIsUserChosen: true + ...(!detached ? { branchNameIsUserChosen: true } : {}) }) } catch (error) { // The switch already landed; bookkeeping must not turn it into a failure. First-work diff --git a/src/main/runtime/integrations/git/runtime-git-command-surface.ts b/src/main/runtime/integrations/git/runtime-git-command-surface.ts index 90da686120f..f0d8a68568b 100644 --- a/src/main/runtime/integrations/git/runtime-git-command-surface.ts +++ b/src/main/runtime/integrations/git/runtime-git-command-surface.ts @@ -26,6 +26,11 @@ type RuntimeGitCommandName = | 'getRuntimeGitBranchDiff' | 'getRuntimeGitCommitDiff' | 'commitRuntimeGit' + | 'createRuntimeGitBranch' + | 'cherryPickRuntimeGit' + | 'revertRuntimeGit' + | 'sequencerAbortRuntimeGit' + | 'sequencerContinueRuntimeGit' | 'completeRuntimeGitMerge' | 'generateRuntimeCommitMessage' | 'discoverRuntimeCommitMessageModels' @@ -74,6 +79,11 @@ export function installRuntimeGitCommandSurface( getRuntimeGitBranchDiff: commands.getRuntimeGitBranchDiff.bind(commands), getRuntimeGitCommitDiff: commands.getRuntimeGitCommitDiff.bind(commands), commitRuntimeGit: commands.commitRuntimeGit.bind(commands), + createRuntimeGitBranch: commands.createRuntimeGitBranch.bind(commands), + cherryPickRuntimeGit: commands.cherryPickRuntimeGit.bind(commands), + revertRuntimeGit: commands.revertRuntimeGit.bind(commands), + sequencerAbortRuntimeGit: commands.sequencerAbortRuntimeGit.bind(commands), + sequencerContinueRuntimeGit: commands.sequencerContinueRuntimeGit.bind(commands), completeRuntimeGitMerge: commands.completeRuntimeGitMerge.bind(commands), generateRuntimeCommitMessage: commands.generateRuntimeCommitMessage.bind(commands), discoverRuntimeCommitMessageModels: commands.discoverRuntimeCommitMessageModels.bind(commands), diff --git a/src/main/runtime/integrations/git/runtime-git-command-target.ts b/src/main/runtime/integrations/git/runtime-git-command-target.ts index afe4a3ca671..6fba9ffdd0b 100644 --- a/src/main/runtime/integrations/git/runtime-git-command-target.ts +++ b/src/main/runtime/integrations/git/runtime-git-command-target.ts @@ -39,7 +39,10 @@ export type RuntimeGitCommandHost = { /** A user-facing reason when an agent is working in the checkout at this path, else null. */ getAgentSwitchBlocker?(worktreePath: string): Promise /** Clear the old branch's review links and push target, then publish the new branch to every client. */ - recordBranchSwitched?(worktree: Pick): void + recordBranchSwitched?( + worktree: Pick, + detached?: boolean + ): void /** Persist lazily created push remotes for orphan cleanup (#17842); sync materialization skips Store to avoid ownership/refspec side effects (#17828). */ persistMaterializedPushTarget?( worktree: Pick, diff --git a/src/main/runtime/integrations/git/runtime-git-status-commands.ts b/src/main/runtime/integrations/git/runtime-git-status-commands.ts index e76d4cc6141..b3b8bc0fc47 100644 --- a/src/main/runtime/integrations/git/runtime-git-status-commands.ts +++ b/src/main/runtime/integrations/git/runtime-git-status-commands.ts @@ -103,14 +103,16 @@ export class RuntimeGitStatusCommands { async checkoutRuntimeGitBranch( worktreeSelector: string, - branch: string + branch: string, + detached = false ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = requireRuntimeGitProvider(target) const worktreePath = target.worktree.path + const checkoutTarget = detached ? branch.toLowerCase() : branch const result = await switchBranchGuarded( worktreePath, - branch, + detached ? { commitId: checkoutTarget } : checkoutTarget, localGitOptionsForTarget(target), { getStatus: () => @@ -118,9 +120,9 @@ export class RuntimeGitStatusCommands { admissionTier: 'interactive', includeLineStats: false }), - checkout: () => provider.checkoutBranch(worktreePath, branch), + checkout: () => provider.checkoutBranch(worktreePath, checkoutTarget, undefined, detached), getAgentBlocker: async () => this.host.getAgentSwitchBlocker?.(worktreePath) ?? null, - onSwitched: () => this.host.recordBranchSwitched?.(target.worktree) + onSwitched: () => this.host.recordBranchSwitched?.(target.worktree, detached) } ) return { ok: true, branch: result.branch } @@ -149,10 +151,10 @@ export class RuntimeGitStatusCommands { onSwitched: () => this.host.recordBranchSwitched?.(target.worktree) } ) - return { ok: true, branch: result.branch } + return { ok: true, branch: result.branch, head: result.head } } catch (error) { if (error instanceof BranchSwitchLandedWithWarningError) { - return { ok: true, branch: error.branch, warning: error.message } + return { ok: true, branch: error.branch, head: error.head, warning: error.message } } throw error } diff --git a/src/main/runtime/integrations/git/runtime-git-sync-commands.ts b/src/main/runtime/integrations/git/runtime-git-sync-commands.ts index c1bd0a63059..da0a56e0798 100644 --- a/src/main/runtime/integrations/git/runtime-git-sync-commands.ts +++ b/src/main/runtime/integrations/git/runtime-git-sync-commands.ts @@ -1,3 +1,4 @@ +import type { GitCommitAction } from '../../../../shared/git/commands/git-commit-action' import type { GitForkSyncExpectedUpstream, GitForkSyncResult @@ -132,6 +133,48 @@ export class RuntimeGitSyncCommands { return requireRuntimeGitProvider(target).commit(target.worktree.path, message) } + async createRuntimeGitBranch(worktreeSelector: string, name: string, startPoint: string) { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + return requireRuntimeGitProvider(target).createBranch(target.worktree.path, name, startPoint) + } + + async cherryPickRuntimeGit(worktreeSelector: string, commitId: string) { + return this.applyRuntimeCommit(worktreeSelector, 'cherry-pick', commitId) + } + + async revertRuntimeGit(worktreeSelector: string, commitId: string) { + return this.applyRuntimeCommit(worktreeSelector, 'revert', commitId) + } + + private async applyRuntimeCommit( + worktreeSelector: string, + operation: GitCommitAction, + commitId: string + ) { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + return requireRuntimeGitProvider(target).applyCommit(target.worktree.path, operation, commitId) + } + + async sequencerAbortRuntimeGit(worktreeSelector: string, operation: GitCommitAction) { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + await requireRuntimeGitProvider(target).recoverSequencer( + target.worktree.path, + operation, + 'abort' + ) + return { ok: true } + } + + async sequencerContinueRuntimeGit(worktreeSelector: string, operation: GitCommitAction) { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const result = await requireRuntimeGitProvider(target).recoverSequencer( + target.worktree.path, + operation, + 'continue' + ) + return result ?? { ok: true } + } + async completeRuntimeGitMerge( worktreeSelector: string ): Promise<{ success: boolean; error?: string }> { diff --git a/src/main/runtime/rpc/dispatcher-error-response.ts b/src/main/runtime/rpc/dispatcher-error-response.ts index 154c9a86125..afb87a3794d 100644 --- a/src/main/runtime/rpc/dispatcher-error-response.ts +++ b/src/main/runtime/rpc/dispatcher-error-response.ts @@ -1,3 +1,4 @@ +import { BranchSwitchLandedWithWarningError } from '../../git/branch-switch' import { mapBrowserError } from './browser-error-response' import { ZodError } from 'zod' import { InvalidArgumentError, formatZodError } from './core' @@ -27,6 +28,11 @@ export function mapDispatcherError( meta: RpcEnvelopeMeta, error: unknown ): RpcResponse { + if (request.method === 'git.checkout' && error instanceof BranchSwitchLandedWithWarningError) { + return errorResponse(request.id, meta, 'runtime_error', error.message, { + outcome: 'landed-with-warning' + }) + } if (error instanceof ZodError) { return invalidArgumentResponse(request, meta, formatZodError(error)) } diff --git a/src/main/runtime/rpc/methods/source-control/git/git-commit-action-methods.ts b/src/main/runtime/rpc/methods/source-control/git/git-commit-action-methods.ts new file mode 100644 index 00000000000..f06916939ba --- /dev/null +++ b/src/main/runtime/rpc/methods/source-control/git/git-commit-action-methods.ts @@ -0,0 +1,44 @@ +import { defineMethod } from '../../../core' +import { + GitCommitAction, + GitSequencer, + GitCreateBranch +} from '../../../../../../shared/rpc-contract/git-params' + +export const GIT_COMMIT_ACTION_METHODS = [ + defineMethod({ + name: 'git.createBranch', + permission: 'workspace', + params: GitCreateBranch, + handler: async (params, { runtime }) => + runtime.createRuntimeGitBranch(params.worktree, params.name, params.startPoint) + }), + defineMethod({ + name: 'git.sequencerContinue', + permission: 'workspace', + params: GitSequencer, + handler: async (params, { runtime }) => + runtime.sequencerContinueRuntimeGit(params.worktree, params.operation) + }), + defineMethod({ + name: 'git.sequencerAbort', + permission: 'workspace', + params: GitSequencer, + handler: async (params, { runtime }) => + runtime.sequencerAbortRuntimeGit(params.worktree, params.operation) + }), + defineMethod({ + name: 'git.cherryPick', + permission: 'workspace', + params: GitCommitAction, + handler: async (params, { runtime }) => + runtime.cherryPickRuntimeGit(params.worktree, params.commitId) + }), + defineMethod({ + name: 'git.revert', + permission: 'workspace', + params: GitCommitAction, + handler: async (params, { runtime }) => + runtime.revertRuntimeGit(params.worktree, params.commitId) + }) +] diff --git a/src/main/runtime/rpc/methods/source-control/git/git-conflict-projection.test.ts b/src/main/runtime/rpc/methods/source-control/git/git-conflict-projection.test.ts new file mode 100644 index 00000000000..53777c46026 --- /dev/null +++ b/src/main/runtime/rpc/methods/source-control/git/git-conflict-projection.test.ts @@ -0,0 +1,60 @@ +import { BranchSwitchLandedWithWarningError } from '../../../../../git/branch-switch' +import { mapDispatcherError } from '../../../dispatcher-error-response' +import '../../../unused-default-rpc-methods.test-fixture' +import { describe, expect, it } from 'vitest' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { RpcDispatcher } from '../../../dispatcher' +import { GIT_METHODS } from './git' +import type { GitStatusResult } from '../../../../../../shared/git/status/git-status-types' + +// The shared status object stands for the host cache; no caller may rewrite it. +describe('git conflict replies', () => { + it('projects revert independently for each caller without changing the cached status', async () => { + const status: GitStatusResult = { entries: [], conflictOperation: 'revert' } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the two fixture methods are dispatched. + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: async () => status, + getRuntimeGitSubmoduleStatus: async () => status, + getRuntimeGitConflictOperation: async () => 'revert' + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + for (const method of ['git.status', 'git.submoduleStatus', 'git.conflictOperation']) { + const replies: string[] = [] + for (const capabilities of [[], ['git.conflict-operation.revert.v1'], []]) { + await dispatcher.dispatchStreaming( + { + id: method, + authToken: '', + method, + params: { worktree: 'id:workspace', submodulePath: 'sub' } + }, + (reply) => replies.push(reply), + { clientCapabilities: capabilities } + ) + } + expect(replies.map((reply) => JSON.parse(reply).result)).toEqual( + method !== 'git.conflictOperation' + ? [ + { entries: [], conflictOperation: 'unknown' }, + { entries: [], conflictOperation: 'revert' }, + { entries: [], conflictOperation: 'unknown' } + ] + : ['unknown', 'revert', 'unknown'] + ) + } + expect(status).toEqual({ entries: [], conflictOperation: 'revert' }) + }) +}) + +it('preserves a landed checkout warning across RPC without changing the legacy error code', () => { + const reply = mapDispatcherError( + { id: 'checkout', authToken: '', method: 'git.checkout' }, + { runtimeId: 'test-runtime' }, + new BranchSwitchLandedWithWarningError('Hook failed after checkout', 'feature') + ) + expect(reply).toMatchObject({ + ok: false, + error: { code: 'runtime_error', data: { outcome: 'landed-with-warning' } } + }) +}) diff --git a/src/main/runtime/rpc/methods/source-control/git/git-status-methods.ts b/src/main/runtime/rpc/methods/source-control/git/git-status-methods.ts new file mode 100644 index 00000000000..d1bd1df21d6 --- /dev/null +++ b/src/main/runtime/rpc/methods/source-control/git/git-status-methods.ts @@ -0,0 +1,86 @@ +import { GIT_REVERT_CONFLICT_CLIENT_CAPABILITY } from '../../../../../../shared/git/commands/git-commit-action' +import type { GitConflictOperation } from '../../../../../../shared/git/status/git-status-types' +import { + GitStatusParams, + GitSubmoduleStatus, + WorktreeSelector +} from '../../../../../../shared/rpc-contract/git-params' +import { defineMethod } from '../../../core' + +function projectConflictOperation( + operation: GitConflictOperation, + capabilities?: readonly string[] +): GitConflictOperation { + return operation === 'revert' && !capabilities?.includes(GIT_REVERT_CONFLICT_CLIENT_CAPABILITY) + ? 'unknown' + : operation +} + +export const GIT_STATUS_METHODS = [ + defineMethod({ + name: 'git.submoduleStatus', + permission: 'workspace', + params: GitSubmoduleStatus, + handler: async (params, { runtime, clientCapabilities }) => { + const status = await runtime.getRuntimeGitSubmoduleStatus( + params.worktree, + params.submodulePath, + params.area + ) + return { + ...status, + conflictOperation: projectConflictOperation(status.conflictOperation, clientCapabilities) + } + } + }), + defineMethod({ + name: 'git.status', + permission: 'workspace', + params: GitStatusParams, + handler: async (params, { runtime, signal, clientCapabilities }) => { + const options = + params.includeIgnored === undefined && + params.includeLineStats === undefined && + params.bypassEffectiveUpstreamNegativeCache === undefined && + params.reuseLineStats === undefined && + params.branchLineTotalMergeBase === undefined && + params.admissionTier === undefined && + signal === undefined + ? undefined + : { + ...(params.includeIgnored === undefined + ? {} + : { includeIgnored: params.includeIgnored }), + ...(params.includeLineStats === undefined + ? {} + : { includeLineStats: params.includeLineStats }), + ...(params.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}), + ...(params.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(params.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: params.branchLineTotalMergeBase }), + admissionTier: params.admissionTier ?? 'status', + ...(signal ? { signal } : {}) + } + const status = await (options === undefined + ? runtime.getRuntimeGitStatus(params.worktree, { admissionTier: 'status' }) + : runtime.getRuntimeGitStatus(params.worktree, options)) + return { + ...status, + conflictOperation: projectConflictOperation(status.conflictOperation, clientCapabilities) + } + } + }), + defineMethod({ + name: 'git.conflictOperation', + permission: 'workspace', + params: WorktreeSelector, + handler: async (params, { runtime, clientCapabilities }) => + projectConflictOperation( + await runtime.getRuntimeGitConflictOperation(params.worktree), + clientCapabilities + ) + }) +] diff --git a/src/main/runtime/rpc/methods/source-control/git/git.ts b/src/main/runtime/rpc/methods/source-control/git/git.ts index ffa48ddaf6a..765985dec8b 100644 --- a/src/main/runtime/rpc/methods/source-control/git/git.ts +++ b/src/main/runtime/rpc/methods/source-control/git/git.ts @@ -1,3 +1,5 @@ +import { GIT_STATUS_METHODS } from './git-status-methods' +import { GIT_COMMIT_ACTION_METHODS } from './git-commit-action-methods' import { defineMethod } from '../../../core' import { GIT_COMMIT_MESSAGE_GENERATION_METHODS } from './git-commit-message-generation-methods' import { GIT_BRANCH_METHODS } from './git-branch-methods' @@ -19,49 +21,12 @@ import { GitRebaseFromBase, GitRemoteCommitUrl, GitRemoteFileUrl, - GitStatusParams, - GitSubmoduleStatus, GitTargetedRemote, WorktreeSelector } from '../../../../../../shared/rpc-contract/git-params' export const GIT_METHODS = [ - defineMethod({ - name: 'git.status', - permission: 'workspace', - params: GitStatusParams, - handler: async (params, { runtime, signal }) => { - const options = - params.includeIgnored === undefined && - params.includeLineStats === undefined && - params.bypassEffectiveUpstreamNegativeCache === undefined && - params.reuseLineStats === undefined && - params.branchLineTotalMergeBase === undefined && - params.admissionTier === undefined && - signal === undefined - ? undefined - : { - ...(params.includeIgnored === undefined - ? {} - : { includeIgnored: params.includeIgnored }), - ...(params.includeLineStats === undefined - ? {} - : { includeLineStats: params.includeLineStats }), - ...(params.bypassEffectiveUpstreamNegativeCache === true - ? { bypassEffectiveUpstreamNegativeCache: true } - : {}), - ...(params.reuseLineStats === true ? { reuseLineStats: true } : {}), - ...(params.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: params.branchLineTotalMergeBase }), - admissionTier: params.admissionTier ?? 'status', - ...(signal ? { signal } : {}) - } - return options === undefined - ? runtime.getRuntimeGitStatus(params.worktree, { admissionTier: 'status' }) - : runtime.getRuntimeGitStatus(params.worktree, options) - } - }), + ...GIT_STATUS_METHODS, defineMethod({ name: 'git.checkIgnored', permission: 'workspace', @@ -69,13 +34,6 @@ export const GIT_METHODS = [ handler: async (params, { runtime }) => runtime.checkRuntimeGitIgnoredPaths(params.worktree, params.paths) }), - defineMethod({ - name: 'git.submoduleStatus', - permission: 'workspace', - params: GitSubmoduleStatus, - handler: async (params, { runtime }) => - runtime.getRuntimeGitSubmoduleStatus(params.worktree, params.submodulePath, params.area) - }), defineMethod({ name: 'git.refs', permission: 'workspace', @@ -96,12 +54,6 @@ export const GIT_METHODS = [ skip: params.skip }) }), - defineMethod({ - name: 'git.conflictOperation', - permission: 'workspace', - params: WorktreeSelector, - handler: async (params, { runtime }) => runtime.getRuntimeGitConflictOperation(params.worktree) - }), defineMethod({ name: 'git.mutationInFlight', permission: 'workspace', @@ -126,12 +78,13 @@ export const GIT_METHODS = [ params: WorktreeSelector, handler: async (params, { runtime }) => runtime.abortRuntimeGitRebase(params.worktree) }), + ...GIT_COMMIT_ACTION_METHODS, defineMethod({ name: 'git.checkout', permission: 'workspace', params: GitCheckout, handler: async (params, { runtime }) => - runtime.checkoutRuntimeGitBranch(params.worktree, params.branch) + runtime.checkoutRuntimeGitBranch(params.worktree, params.branch, params.detached) }), defineMethod({ name: 'git.localBranches', diff --git a/src/main/runtime/rpc/methods/workspace-layout/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout/workspace-layout-apply.test.ts index b344d35b110..2dc84aef7e6 100644 --- a/src/main/runtime/rpc/methods/workspace-layout/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout/workspace-layout-apply.test.ts @@ -1,3 +1,8 @@ +import { RUNTIME_CAPABILITIES } from '../../../../../shared/protocol-version' +import { + LAYOUT_INTENTS_RUNTIME_CAPABILITY, + LAYOUT_REOPEN_HELD_PANES_CAPABILITY +} from '../../../../../shared/workspace-layout/layout-intents-capability' import { afterEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceSessionState } from '../../../../../shared/workspace-session/workspace-session-state-types' @@ -71,6 +76,11 @@ afterEach(() => { }) describe('layout.apply', () => { + it('does not advertise layout mutations in a window-owned build', () => { + expect(RUNTIME_CAPABILITIES).not.toContain(LAYOUT_INTENTS_RUNTIME_CAPABILITY) + expect(RUNTIME_CAPABILITIES).not.toContain(LAYOUT_REOPEN_HELD_PANES_CAPABILITY) + }) + it('gives each title in its tab’s partition in one write, naming a paired server’s tab', async () => { const applyTabRenames = vi.fn>(async (batches) => [ { rename: batches[0]!.renames[0]!, code: 'tab_not_found' } diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts index 6cff7e28800..58607ebc9e1 100644 --- a/src/preload/api/git-bridge.ts +++ b/src/preload/api/git-bridge.ts @@ -1,3 +1,7 @@ +import type { + GitCommitAction, + GitCommitActionResult +} from '../../shared/git/commands/git-commit-action' import { ipcRenderer } from 'electron' import type { GitForkSyncExpectedUpstream, @@ -146,6 +150,32 @@ export const gitApi = { message: string connectionId?: string }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), + createBranch: (args: { + worktreePath: string + name: string + startPoint: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:createBranch', args), + cherryPick: (args: { + worktreePath: string + commitId: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cherryPick', args), + revert: (args: { + worktreePath: string + commitId: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:revert', args), + sequencerAbort: (args: { + worktreePath: string + operation: GitCommitAction + connectionId?: string + }): Promise => ipcRenderer.invoke('git:sequencerAbort', args), + sequencerContinue: (args: { + worktreePath: string + operation: GitCommitAction + connectionId?: string + }): Promise => ipcRenderer.invoke('git:sequencerContinue', args), completeMerge: (args: { worktreePath: string connectionId?: string diff --git a/src/preload/api/git-operation-api.ts b/src/preload/api/git-operation-api.ts index 9274f2b7b8f..0a8bdad2c86 100644 --- a/src/preload/api/git-operation-api.ts +++ b/src/preload/api/git-operation-api.ts @@ -1,3 +1,7 @@ +import type { + GitCommitAction, + GitCommitActionResult +} from '../../shared/git/commands/git-commit-action' import type { GitForkSyncExpectedUpstream, GitForkSyncResult @@ -52,6 +56,32 @@ export type GitOperationApi = { message: string connectionId?: string }) => Promise<{ success: boolean; error?: string }> + createBranch: (args: { + worktreePath: string + name: string + startPoint: string + connectionId?: string + }) => Promise + cherryPick: (args: { + worktreePath: string + commitId: string + connectionId?: string + }) => Promise + revert: (args: { + worktreePath: string + commitId: string + connectionId?: string + }) => Promise + sequencerAbort: (args: { + worktreePath: string + operation: GitCommitAction + connectionId?: string + }) => Promise + sequencerContinue: (args: { + worktreePath: string + operation: GitCommitAction + connectionId?: string + }) => Promise completeMerge: (args: { worktreePath: string connectionId?: string diff --git a/src/renderer/src/components/editor/git-graph/git-graph-commit-action-controls.tsx b/src/renderer/src/components/editor/git-graph/git-graph-commit-action-controls.tsx new file mode 100644 index 00000000000..6841d12833c --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-commit-action-controls.tsx @@ -0,0 +1,129 @@ +import { Input } from '@/components/ui/input' +import { useId } from 'react' +import { isBranchNameValid } from '../../../../../shared/git/branch-switch-target' +import { useStore } from 'zustand' +import { Button } from '@/components/ui/button' +import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu' +import { useTranslate } from '@/i18n/use-translate' +import type { useGitCommitActionsSupport } from '@/runtime/source-control/git/git-commit-actions-capability' +import type { GitHistoryItem } from '../../../../../shared/git/history/git-history-types' +import type { GitGraphCommitActionsController } from './git-graph-commit-actions-controller' +import { gitGraphActionLabel, type GitGraphCommitAction } from './git-graph-commit-action-copy' + +type Props = { + item: GitHistoryItem + controller: GitGraphCommitActionsController + support: ReturnType + menu?: boolean +} +export function GitGraphCommitActionControls({ item, controller, support, menu }: Props) { + const t = useTranslate() + const running = useStore(controller.view, (state) => state.running) + const merge = item.parentIds.length > 1 + const reason = + support.supported && merge + ? t('gitGraph.cannotApplyMerge', "Can't cherry-pick/revert a merge commit from here") + : support.reason + return ( + <> +

+ {(['checkout', 'create-branch', 'cherry-pick', 'revert'] as const).map( + (action: GitGraphCommitAction) => { + const disabled = + running || + !support.supported || + (merge && (action === 'cherry-pick' || action === 'revert')) + return menu ? ( + controller.request(action, item)} + > + {gitGraphActionLabel(action, t)} + + ) : ( + + ) + } + )} +
+ {reason &&

{reason}

} + {menu && } + + ) +} +export function GitGraphCommitConfirmStrip({ + controller +}: { + controller: GitGraphCommitActionsController +}) { + const t = useTranslate() + const { pending } = useStore(controller.view) + const branchNameErrorId = useId() + if (!pending) { + return null + } + const creating = pending.action === 'create-branch' + const validName = isBranchNameValid(pending.name ?? '') + const invalidName = !!pending.name && !validName + return ( +
{ + event.preventDefault() + if (!creating || validName) { + void controller.confirm() + } + }} + className="space-y-2 border-b border-border bg-muted p-3 text-xs" + onKeyDown={(event) => { + if (event.key === 'Escape') { + controller.cancel() + } + }} + > +

+ {t('gitGraph.confirmCommitAction', '{{action}} {{commit}}?', { + action: gitGraphActionLabel(pending.action, t), + commit: pending.branchTarget?.name ?? pending.item.id.slice(0, 7) + })} +

+

{pending.item.subject}

+ {creating && ( + <> + controller.setBranchName(event.target.value)} + /> + {invalidName && ( +

+ {t( + 'gitGraph.invalidBranchName', + 'This branch name is invalid. Choose a different name.' + )} +

+ )} + + )} +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-commit-action-copy.ts b/src/renderer/src/components/editor/git-graph/git-graph-commit-action-copy.ts new file mode 100644 index 00000000000..c2d33d145d7 --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-commit-action-copy.ts @@ -0,0 +1,108 @@ +import { translate } from '@/i18n/i18n' +import { displayIpcErrorMessage } from '@/lib/ipc-error' +import type { Translate } from '@/i18n/use-translate' +import type { GitCommitActionResult } from '../../../../../shared/git/commands/git-commit-action' + +export type GitGraphCommitAction = + | 'checkout' + | 'checkout-branch' + | 'create-branch' + | 'cherry-pick' + | 'revert' +export function gitGraphActionMessage(result: GitCommitActionResult): string | undefined { + const message = result.message && displayIpcErrorMessage(result.message) + if ( + message === + "Orca couldn't create the branch. Check this repository in a terminal, then try again." + ) { + return translate( + 'gitGraph.createBranchFailed', + "Orca couldn't create the branch. Check this repository in a terminal, then try again." + ) + } + if (message === 'Commit or stash your changes, then try again.') { + return translate('gitGraph.actionDirty', 'Commit or stash your changes, then try again.') + } + if (message === 'Git did not create a commit. Check the checkout and try again.') { + return translate( + 'gitGraph.actionFailed', + 'Git did not create a commit. Check the checkout and try again.' + ) + } + const operation = + /^Finish or abort the (merge|rebase|cherry-pick|revert) in progress before trying again\.$/.exec( + message ?? '' + )?.[1] + if (operation) { + const label = + operation === 'merge' + ? translate('auto.components.right.sidebar.checks.panel.content.operation.merge', 'Merge') + : operation === 'rebase' + ? translate( + 'auto.components.right.sidebar.checks.panel.content.operation.rebase', + 'Rebase' + ) + : gitGraphActionLabel(operation === 'revert' ? 'revert' : 'cherry-pick') + return translate( + 'gitGraph.operationInProgress', + 'Finish or abort the {{operation}} in progress before trying again.', + { operation: label } + ) + } + return message +} +export function gitGraphActionLabel( + action: GitGraphCommitAction, + t: Translate = translate +): string { + switch (action) { + case 'checkout-branch': + return t('gitGraph.checkoutBranch', 'Check out branch') + case 'create-branch': + return t('gitGraph.createBranch', 'Create branch here') + case 'checkout': + return t('gitGraph.checkoutCommit', 'Check out commit') + case 'cherry-pick': + return t('gitGraph.cherryPick', 'Cherry-pick') + case 'revert': + return t('gitGraph.revert', 'Revert') + } +} +export function gitGraphActionOutcome( + result: GitCommitActionResult, + action?: GitGraphCommitAction +): string { + switch (result.outcome) { + case 'landed': + if (action === 'create-branch') { + return translate('gitGraph.branchCreated', 'Created {{branch}} at {{commit}}', { + branch: result.branch, + commit: result.commitId?.slice(0, 7) + }) + } + return translate('gitGraph.actionLanded', 'Now at {{commit}} on {{branch}}', { + commit: result.commitId?.slice(0, 7), + branch: result.branch || translate('gitGraph.detachedHead', 'detached HEAD') + }) + case 'landed-with-warning': + return translate( + 'gitGraph.actionWarning', + 'The action completed, but Git reported a problem.' + ) + case 'conflicted': + return translate( + 'gitGraph.actionConflicted', + 'Resolve the conflicts in Source Control, then continue or abort the operation.' + ) + case 'empty': + if (action === 'revert') { + return translate( + 'gitGraph.nothingToRevert', + 'Nothing to revert; the changes are already undone.' + ) + } + return translate('gitGraph.alreadyApplied', 'Already applied, nothing to cherry-pick') + case 'refused': + return translate('gitGraph.actionRefused', 'The action did not complete.') + } +} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-commit-actions-controller.test.ts b/src/renderer/src/components/editor/git-graph/git-graph-commit-actions-controller.test.ts new file mode 100644 index 00000000000..03f35752c4e --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-commit-actions-controller.test.ts @@ -0,0 +1,217 @@ +import * as branchActions from '@/runtime/source-control/git/runtime-git-branch-client' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +// @vitest-environment happy-dom +import * as commitSupport from '@/runtime/source-control/git/git-commit-actions-capability' +import * as commitActions from '@/runtime/source-control/git/runtime-git-commit-actions-client' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { gitGraphHistoryForTab, disposeGitGraphHistory } from './git-graph-tab-controllers' +import { workspaceId, local, repo } from './git-graph-workspace.test-fixtures' +import type { GitHistoryResult } from '../../../../../shared/git/history/git-history-types' +import { beforeEach, afterEach, expect, it, vi } from 'vitest' + +const { getHistory, getStatus } = vi.hoisted(() => ({ + getHistory: vi.fn(), + getStatus: vi.fn() +})) +vi.mock('@/runtime/source-control/git/runtime-git-status-client', async (original) => ({ + ...(await original()), + getRuntimeGitHistory: getHistory, + getRuntimeGitStatus: getStatus +})) +const initial = useAppStore.getState() +const result: GitHistoryResult = { + items: [ + { id: 'a', subject: 'source', message: 'source', parentIds: [] }, + { id: 'b', subject: 'newer selection', message: 'newer selection', parentIds: [] } + ], + hasMore: false, + hasIncomingChanges: false, + hasOutgoingChanges: false, + limit: 50 +} +let detach: (() => void) | undefined +beforeEach(() => { + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { r: [local] }, + activeWorktreeId: workspaceId, + activeWorkspaceExecutionHostId: 'local', + gitGraphFiltersByWorktree: {}, + gitConflictOperationByWorktree: {} + }) + getHistory.mockReset().mockResolvedValue(result) + getStatus.mockReset().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + }) + vi.spyOn(commitSupport, 'gitCommitActionsSupported').mockResolvedValue(true) +}) +afterEach(() => { + detach?.() + disposeGitGraphHistory(`${workspaceId}::git-graph`) + useAppStore.setState(initial, true) + vi.restoreAllMocks() +}) + +it('refreshes and announces a landed action after navigation without replacing the newer selection', async () => { + const controller = gitGraphHistoryForTab(workspaceId) + detach = controller.attach() + await controller.settledResult() + const completion = Promise.withResolvers<{ + outcome: 'landed' + commitId: string + branch: string + }>() + const started = Promise.withResolvers() + vi.spyOn(commitActions, 'cherryPickRuntimeGit').mockImplementation(() => { + started.resolve() + return completion.promise + }) + const announce = vi.spyOn(toast, 'message') + controller.actions.request('cherry-pick', result.items[0]!) + const applying = controller.actions.confirm() + await started.promise + controller.select('b') + getHistory.mockResolvedValue({ + ...result, + items: [ + ...result.items, + { id: 'ccccccc', subject: 'created', message: 'created', parentIds: [] } + ] + }) + completion.resolve({ outcome: 'landed', commitId: 'ccccccc', branch: 'main' }) + await applying + expect(announce).toHaveBeenCalled() + expect(controller.view.getState().selectedId).toBe('b') + expect(controller.view.getState().result?.items.map((item) => item.id)).toEqual([ + 'a', + 'b', + 'ccccccc' + ]) +}) + +it('selects the created commit through the filter reload when showing all branches', async () => { + useAppStore.setState({ gitGraphFiltersByWorktree: { [workspaceId]: 'head' } }) + const controller = gitGraphHistoryForTab(workspaceId) + detach = controller.attach() + await controller.settledResult() + vi.spyOn(commitActions, 'cherryPickRuntimeGit').mockResolvedValue({ + outcome: 'landed', + commitId: 'ccccccc', + branch: 'main' + }) + const announce = vi.spyOn(toast, 'message') + controller.actions.request('cherry-pick', result.items[0]!) + await controller.actions.confirm() + const action = announce.mock.calls.at(-1)?.[1]?.action + if (!action || typeof action !== 'object' || !('onClick' in action)) { + throw new Error('Expected the outcome to offer all branches') + } + getHistory.mockResolvedValue({ + ...result, + items: [ + ...result.items, + { id: 'ccccccc', subject: 'created', message: 'created', parentIds: [] } + ] + }) + const container = document.createElement('div') + const root = createRoot(container) + await act(async () => root.render(createElement('button', { onClick: action.onClick }))) + await act(async () => { + container.querySelector('button')!.click() + await controller.settledResult() + }) + await act(async () => root.unmount()) + expect(useAppStore.getState().gitGraphFiltersByWorktree?.[workspaceId]).toBe('all') + expect(controller.view.getState().selectedId).toBe('ccccccc') +}) + +it.each([ + ['refs/heads/feature', 'local', 'feature'], + ['refs/remotes/origin/topic', 'remote', 'origin/topic'] +] as const)( + 'checks out %s through the shared branch switch and reports its landed warning', + async (id, kind, name) => { + const controller = gitGraphHistoryForTab(workspaceId) + detach = controller.attach() + await controller.settledResult() + const switching = vi.spyOn(branchActions, 'switchRuntimeGitBranch').mockResolvedValue({ + ok: true, + branch: 'my-topic', + head: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + warning: + "Error invoking remote method 'git:switchBranch': Error: Tracking configured; hook failed." + }) + getHistory.mockResolvedValue({ + ...result, + items: [ + ...result.items, + { + id: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + subject: 'advanced tracker', + message: 'advanced tracker', + parentIds: [] + } + ] + }) + const announce = vi.spyOn(toast, 'message') + controller.actions.requestBranch(result.items[0]!, { id, name }) + await controller.actions.confirm() + expect(switching).toHaveBeenCalledWith( + { + target: { kind: 'local' }, + worktreeId: workspaceId, + worktreePath: local.path, + connectionId: undefined + }, + { kind, name }, + undefined, + expect.any(Function) + ) + expect(controller.view.getState().selectedId).toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb') + expect(announce.mock.calls.at(-1)?.[1]?.description).toBe( + 'Now at bbbbbbb on my-topic\nTracking configured; hook failed.' + ) + } +) + +it('shows refreshed conflict status by the time the action outcome is announced', async () => { + const controller = gitGraphHistoryForTab(workspaceId) + detach = controller.attach() + await controller.settledResult() + vi.spyOn(commitActions, 'cherryPickRuntimeGit').mockResolvedValue({ outcome: 'conflicted' }) + getStatus.mockResolvedValue({ + entries: [], + conflictOperation: 'cherry-pick', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + }) + let operationAtAnnouncement: string | undefined + vi.spyOn(toast, 'message').mockImplementation(() => { + operationAtAnnouncement = useAppStore.getState().gitConflictOperationByWorktree[workspaceId] + return 'outcome' + }) + controller.actions.request('cherry-pick', result.items[0]!) + await controller.actions.confirm() + expect(operationAtAnnouncement).toBe('cherry-pick') +}) + +it('shows a local create-branch refusal without the IPC wrapper', async () => { + const controller = gitGraphHistoryForTab(workspaceId) + detach = controller.attach() + await controller.settledResult() + vi.spyOn(commitActions, 'createRuntimeGitBranch').mockRejectedValue( + new Error( + "Error invoking remote method 'git:createBranch': Error: A local branch named topic already exists. Switch to that branch instead." + ) + ) + const announce = vi.spyOn(toast, 'message') + controller.actions.request('create-branch', result.items[0]!) + controller.actions.setBranchName('topic') + await controller.actions.confirm() + expect(announce.mock.calls.at(-1)?.[1]?.description).toBe( + 'A local branch named topic already exists. Switch to that branch instead.' + ) +}) diff --git a/src/renderer/src/components/editor/git-graph/git-graph-commit-actions-controller.ts b/src/renderer/src/components/editor/git-graph/git-graph-commit-actions-controller.ts new file mode 100644 index 00000000000..ac69ad7ee1f --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-commit-actions-controller.ts @@ -0,0 +1,255 @@ +import { refreshGitStatusForWorktree } from '@/components/right-sidebar/source-control/sync/git-status-refresh' +import { switchRuntimeGitBranch } from '@/runtime/source-control/git/runtime-git-branch-client' +import type { BranchSwitchTarget } from '../../../../../shared/git/branch-switch-target' +import { createStore } from 'zustand/vanilla' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { gitGraphScope } from '@/lib/git-graph-tab' +import { gitGraphOwnerKey } from './git-graph-workspace-navigation' +import { gitCommitActionsSupported } from '@/runtime/source-control/git/git-commit-actions-capability' +import { + createRuntimeGitBranch, + cherryPickRuntimeGit, + revertRuntimeGit +} from '@/runtime/source-control/git/runtime-git-commit-actions-client' +import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' +import { RuntimeRpcCallError } from '@/runtime/rpc/calls/runtime-rpc-result' +import { toRuntimeWorktreeSelector } from '@/runtime/rpc/targets/runtime-worktree-selector' +import { translate } from '@/i18n/i18n' +import type { GitGraphHistoryController } from './git-graph-history-controller' +import type { + GitHistoryItem, + GitHistoryItemRef +} from '../../../../../shared/git/history/git-history-types' +import type { GitCommitActionResult } from '../../../../../shared/git/commands/git-commit-action' +import { + gitGraphActionOutcome, + gitGraphActionMessage, + type GitGraphCommitAction +} from './git-graph-commit-action-copy' + +type PendingCommitAction = { + action: GitGraphCommitAction + branchTarget?: BranchSwitchTarget + name?: string + item: GitHistoryItem + scope: NonNullable> +} +export class GitGraphCommitActionsController { + readonly view = createStore<{ pending?: PendingCommitAction; running: boolean }>(() => ({ + running: false + })) + constructor(private readonly history: GitGraphHistoryController) {} + cancel = () => { + this.view.setState({ pending: undefined }) + } + request = ( + action: GitGraphCommitAction, + item: GitHistoryItem, + branchTarget?: BranchSwitchTarget + ) => { + const scope = this.history.actionScope + if (!scope || this.view.getState().running) { + return + } + this.history.select(item.id) + this.view.setState({ pending: { action, item, scope, branchTarget } }) + } + requestBranch = (item: GitHistoryItem, ref: GitHistoryItemRef) => { + const kind = ref.id.startsWith('refs/heads/') + ? 'local' + : ref.id.startsWith('refs/remotes/') + ? 'remote' + : null + if (!kind) { + return + } + this.request('checkout-branch', item, { + kind, + name: ref.id.slice(kind === 'local' ? 'refs/heads/'.length : 'refs/remotes/'.length) + }) + } + setBranchName = (name: string) => { + const pending = this.view.getState().pending + if (pending?.action === 'create-branch') { + this.view.setState({ pending: { ...pending, name } }) + } + } + private owns(scope: PendingCommitAction['scope']): boolean { + return ( + gitGraphOwnerKey(scope) === + gitGraphOwnerKey( + gitGraphScope( + this.history.worktreeId, + useAppStore.getState(), + scope.route.executionHostId ?? undefined + ) + ) + ) + } + confirm = async (): Promise => { + const pending = this.view.getState().pending + if (!pending || this.view.getState().running) { + return + } + const { scope, item, action } = pending + const current = this.history.captureActionView() + this.view.setState({ pending: undefined, running: true }) + const checkOwner = () => { + if (!this.owns(scope)) { + throw new Error( + translate( + 'gitGraph.ownerChanged', + 'The workspace host changed. Reopen the Git Graph and try again.' + ) + ) + } + } + let result: GitCommitActionResult + try { + if (!(await gitCommitActionsSupported(scope.target)) || !current() || !this.owns(scope)) { + throw new Error( + translate( + 'gitGraph.actionUnavailable', + 'This workspace is no longer available for the action. Reopen the Git Graph and try again.' + ) + ) + } + const context = { + target: scope.target, + worktreeId: this.history.worktreeId, + worktreePath: scope.worktree.path, + connectionId: scope.connectionId ?? undefined + } + if (action === 'checkout-branch' && pending.branchTarget) { + const switched = await switchRuntimeGitBranch( + context, + pending.branchTarget, + undefined, + checkOwner + ) + result = { + outcome: switched.warning ? 'landed-with-warning' : 'landed', + branch: switched.branch, + commitId: switched.head, + message: switched.warning + } + } else if (action === 'create-branch') { + result = await createRuntimeGitBranch(context, pending.name ?? '', item.id, checkOwner) + } else if (action === 'checkout') { + await callRuntimeRpc( + scope.target, + 'git.checkout', + { + worktree: toRuntimeWorktreeSelector(this.history.worktreeId), + branch: item.id, + detached: true + }, + { + timeoutMs: 30_000, + beforeSend: checkOwner + } + ) + result = { outcome: 'landed', commitId: item.id } + } else { + result = await (action === 'cherry-pick' ? cherryPickRuntimeGit : revertRuntimeGit)( + context, + item.id, + checkOwner + ) + } + } catch (error) { + const data = error instanceof RuntimeRpcCallError ? error.response.error.data : undefined + const landed = + data !== null && + typeof data === 'object' && + 'outcome' in data && + data.outcome === 'landed-with-warning' + result = { + outcome: landed ? 'landed-with-warning' : 'refused', + ...(landed ? { commitId: item.id } : {}), + message: error instanceof Error ? error.message : String(error) + } + } + // Only the follow-up view is fenced; the user always hears what the confirmed action did. + let outside = false + if (this.owns(scope)) { + const selectResult = current() + const refreshing = this.history.refresh() + const afterRefresh = this.history.captureActionView() + const shouldApplyStatus = () => + useAppStore.getState().activeWorktreeId === this.history.worktreeId && + this.owns(scope) && + gitGraphOwnerKey(gitGraphScope(this.history.worktreeId)) === gitGraphOwnerKey(scope) + const statusRefresh = shouldApplyStatus() + ? refreshGitStatusForWorktree({ + target: scope.target, + ownerHostId: scope.route.executionHostId, + worktreeId: this.history.worktreeId, + worktreePath: scope.worktree.path, + connectionId: scope.connectionId ?? undefined, + pushTarget: scope.worktree.pushTarget, + deps: useAppStore.getState(), + request: { shouldApply: shouldApplyStatus } + }).catch(() => {}) + : undefined + const [refreshed] = await Promise.all([refreshing, statusRefresh]) + if (selectResult && afterRefresh() && this.owns(scope) && result.commitId) { + outside = !refreshed?.items.some((commit) => commit.id === result.commitId) + if (!outside && this.owns(scope)) { + this.history.select(result.commitId) + } + } + } + this.view.setState({ running: false }) + const description = [ + result.outcome === 'landed-with-warning' + ? gitGraphActionOutcome({ ...result, outcome: 'landed' }, action) + : '', + result.outcome === 'empty' ? '' : gitGraphActionMessage(result) + ] + .filter(Boolean) + .join('\n') + toast.message(gitGraphActionOutcome(result, action), { + description, + ...(outside && result.commitId + ? { + action: { + label: translate('gitGraph.showAllBranches', 'Show all branches'), + onClick: () => { + if (this.owns(scope) && this.history.captureActionView()()) { + void this.showAll(result, action) + } + } + } + } + : {}) + }) + } + private async showAll( + outcome: GitCommitActionResult, + action: GitGraphCommitAction + ): Promise { + const commitId = outcome.commitId + if (!commitId) { + return + } + useAppStore.setState((state) => ({ + gitGraphFiltersByWorktree: { + ...state.gitGraphFiltersByWorktree, + [this.history.worktreeId]: 'all' + } + })) + const refreshing = this.history.settledResult() + const current = this.history.captureActionView() + let result = await refreshing + if (current() && !result?.items.some((item) => item.id === commitId) && result?.hasMore) { + result = await this.history.loadMore() + } + if (current() && result?.items.some((item) => item.id === commitId)) { + this.history.select(commitId) + } else if (current()) { + toast.message(gitGraphActionOutcome(outcome, action)) + } + } +} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-details.tsx b/src/renderer/src/components/editor/git-graph/git-graph-details.tsx index d36225bbe8d..5945084cc7f 100644 --- a/src/renderer/src/components/editor/git-graph/git-graph-details.tsx +++ b/src/renderer/src/components/editor/git-graph/git-graph-details.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import { useEffect, useState } from 'react' import { Copy } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -13,10 +14,12 @@ import { useIntlLocale, useTranslate } from '@/i18n/use-translate' export function GitGraphDetails({ item, + commitActions, actions, onParent }: { item: GitHistoryItem + commitActions?: ReactNode actions: ReturnType onParent: (id: string) => void }) { @@ -49,6 +52,7 @@ export function GitGraphDetails({

{item.subject}

+ {commitActions} {body &&

{body}

}
diff --git a/src/renderer/src/components/editor/git-graph/git-graph-history-controller.ts b/src/renderer/src/components/editor/git-graph/git-graph-history-controller.ts index ff19389cf06..3465685eb14 100644 --- a/src/renderer/src/components/editor/git-graph/git-graph-history-controller.ts +++ b/src/renderer/src/components/editor/git-graph/git-graph-history-controller.ts @@ -1,3 +1,5 @@ +import type { GitGraphHistoryView } from './git-graph-history-view' +import { GitGraphCommitActionsController } from './git-graph-commit-actions-controller' import { gitGraphOwnerKey, switchGitGraphWorkspace } from './git-graph-workspace-navigation' import { createStore } from 'zustand/vanilla' import { shallow } from 'zustand/shallow' @@ -11,34 +13,20 @@ import type { } from '../../../../../shared/git/history/git-history-types' import type { ExecutionHostId } from '../../../../../shared/execution-host' -type HistoryView = { - result?: GitHistoryResult - error?: string - loading: boolean - selectedId?: string - revealId?: string -} -const controllers = new Map() const filterFor = (worktreeId: string) => useAppStore.getState().gitGraphFiltersByWorktree?.[worktreeId] ?? 'all' -export function gitGraphHistoryForTab(worktreeId: string): GitGraphHistoryController { - const id = `${worktreeId}::git-graph` - let controller = controllers.get(id) - if (!controller) { - controller = new GitGraphHistoryController(worktreeId) - controllers.set(id, controller) - } - return controller -} - -export function disposeGitGraphHistory(tabId: string): void { - controllers.get(tabId)?.dispose() - controllers.delete(tabId) -} - export class GitGraphHistoryController { - readonly view = createStore(() => ({ loading: false })) + readonly actions = new GitGraphCommitActionsController(this) + get actionScope() { + return this.scope + } + captureActionView(): () => boolean { + const generation = this.generation + const navigation = this.navigation + return () => !this.disposed && generation === this.generation && navigation === this.navigation + } + readonly view = createStore(() => ({ loading: false })) scrollOffset = 0 graphWidth = 132 private scope: ReturnType @@ -102,6 +90,7 @@ export class GitGraphHistoryController { ) { return } + this.actions.cancel() this.scope = scope this.filter = filter this.scrollOffset = 0 @@ -125,6 +114,7 @@ export class GitGraphHistoryController { } dispose(): void { + this.actions.cancel() this.disposed = true this.generation++ this.unsubscribe?.() @@ -141,6 +131,7 @@ export class GitGraphHistoryController { } select = (selectedId: string): void => { + this.actions.cancel() this.navigation++ this.view.setState({ selectedId, revealId: undefined }) } @@ -153,9 +144,12 @@ export class GitGraphHistoryController { return this.retrieve(false, true) } loadMore = (): Promise => this.retrieve(true) + settledResult = (): Promise => + this.pending ?? (this.dirty ? this.retrieve() : Promise.resolve(this.view.getState().result)) reveal = async (id: string): Promise => { const generation = this.generation + this.actions.cancel() const navigation = ++this.navigation this.clearReveal() const current = () => @@ -279,6 +273,7 @@ export class GitGraphHistoryController { result?.items.find((item) => item.id === selection)?.id ?? result?.items[0]?.id const selectionGone = selection !== undefined && selection !== selectedId if (selectionGone) { + this.actions.cancel() this.scrollOffset = 0 } this.view.setState({ diff --git a/src/renderer/src/components/editor/git-graph/git-graph-history-view.ts b/src/renderer/src/components/editor/git-graph/git-graph-history-view.ts new file mode 100644 index 00000000000..a5cadd33cb0 --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-history-view.ts @@ -0,0 +1,9 @@ +import type { GitHistoryResult } from '../../../../../shared/git/history/git-history-types' + +export type GitGraphHistoryView = { + result?: GitHistoryResult + error?: string + loading: boolean + selectedId?: string + revealId?: string +} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-ref-menu.tsx b/src/renderer/src/components/editor/git-graph/git-graph-ref-menu.tsx index dd89d3efd2d..0b968d353da 100644 --- a/src/renderer/src/components/editor/git-graph/git-graph-ref-menu.tsx +++ b/src/renderer/src/components/editor/git-graph/git-graph-ref-menu.tsx @@ -1,3 +1,6 @@ +import { useStore } from 'zustand' +import type { GitGraphCommitActionsController } from './git-graph-commit-actions-controller' +import type { useGitCommitActionsSupport } from '@/runtime/source-control/git/git-commit-actions-capability' import { useMemo, useState } from 'react' import { ArrowUpRight } from 'lucide-react' import { @@ -36,14 +39,21 @@ export function GitGraphRefs({ references, currentRefId, workspaces, - onSwitch + onSwitch, + onCheckout, + controller, + support }: { references: GitHistoryItemRef[] currentRefId?: string workspaces: Map onSwitch: (refId: string) => Promise + onCheckout: (ref: GitHistoryItemRef) => void + controller: GitGraphCommitActionsController + support: ReturnType }) { const t = useTranslate() + const running = useStore(controller.view, (state) => state.running) const [open, setOpen] = useState(false) const [menuRef, setMenuRef] = useState() const current = references.find((ref) => ref.id === currentRefId) @@ -112,12 +122,15 @@ export function GitGraphRefs({ {references.length > inline.length && ( - {open && ( - + event.stopPropagation()}>
{references.filter((ref) => !inline.includes(ref)).map(badge)}
@@ -127,10 +140,25 @@ export function GitGraphRefs({ )}
{menuRef && ( - + event.stopPropagation()}> void window.api.ui.writeClipboardText(menuRef.name)}> {t('gitGraph.copyBranch', 'Copy branch name')} + {menuRef.id !== currentRefId && + !workspaces.has(menuRef.id) && + (menuRef.id.startsWith('refs/heads/') || menuRef.id.startsWith('refs/remotes/')) && ( + <> + onCheckout(menuRef)} + > + {t('gitGraph.checkoutBranch', 'Check out branch')} + + {support.reason && ( +

{support.reason}

+ )} + + )} {workspaces.has(menuRef.id) && ( void onSwitch(menuRef.id)}> {t('gitGraph.switchWorkspace', 'Switch to workspace')} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-rendering.test.tsx b/src/renderer/src/components/editor/git-graph/git-graph-rendering.test.tsx index 8687923b78f..d04bcf20276 100644 --- a/src/renderer/src/components/editor/git-graph/git-graph-rendering.test.tsx +++ b/src/renderer/src/components/editor/git-graph/git-graph-rendering.test.tsx @@ -1,11 +1,10 @@ +import { workspaceId, local, repo } from './git-graph-workspace.test-fixtures' // @vitest-environment happy-dom import { useAppStore } from '@/store' -import { gitGraphHistoryForTab, disposeGitGraphHistory } from './git-graph-history-controller' +import { gitGraphHistoryForTab, disposeGitGraphHistory } from './git-graph-tab-controllers' import { attachClosedEditorTabCleanup } from '../model-lifetime/retention/closed-editor-tab-controller' import { gitGraphScope } from '@/lib/git-graph-tab' import { captureWorktreeOperationGenerationSnapshot } from '@/lib/worktree-operation-generation' -import type { Worktree } from '../../../../../shared/worktree/types' -import type { Repo } from '../../../../../shared/repo-types' import type { OpenFile } from '@/store/slices/editor/types/open-file' import { act } from 'react' import { createRoot } from 'react-dom/client' @@ -13,6 +12,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { GitGraphTab } from './git-graph-tab' import { GitGraphFilter } from './git-graph-filter' +import * as commitSupport from '@/runtime/source-control/git/git-commit-actions-capability' import { useGitGraphHistory } from './use-git-graph-history' import type { GitHistoryResult } from '../../../../../shared/git/history/git-history-types' @@ -44,35 +44,6 @@ const container = document.createElement('div') document.body.appendChild(container) let root = createRoot(container) const initialState = useAppStore.getState() -const workspaceId = 'r::/repo' -const local: Worktree = { - id: workspaceId, - repoId: 'r', - path: '/repo', - branch: 'refs/heads/main', - hostId: 'local', - head: 'a', - isBare: false, - isMainWorktree: true, - displayName: 'repo', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0 -} -const repo: Repo = { - id: 'r', - path: '/repo', - displayName: 'repo', - badgeColor: '', - addedAt: 0, - executionHostId: 'local' -} beforeEach(() => { useAppStore.setState({ repos: [repo], @@ -97,6 +68,62 @@ const result: GitHistoryResult = { limit: 50 } +it('keeps the branch checkout strip open after selecting a badge context-menu action', async () => { + vi.spyOn(commitSupport, 'useGitCommitActionsSupport').mockReturnValue({ + supported: true, + reason: undefined + }) + getHistory.mockReset().mockResolvedValue({ + ...result, + items: [ + { + ...result.items[0], + references: [{ id: 'refs/heads/topic', name: 'topic' }] + } + ] + }) + await act(async () => + root.render( + + + + ) + ) + await act(async () => { + container + .querySelector('[title="topic"]')! + .dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, button: 2 })) + }) + await act(async () => { + Array.from(document.querySelectorAll('[role="menuitem"]')) + .find((item) => item.textContent === 'Check out branch')! + .click() + }) + expect(container.querySelector('form')?.textContent).toContain('Check out branch topic?') +}) + +it('renders converging branch lanes and a detached root without duplicate child keys', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + getHistory.mockReset().mockResolvedValue({ + ...result, + items: [ + { id: 'a', subject: 'a', message: 'a', parentIds: ['root'] }, + { id: 'b', subject: 'b', message: 'b', parentIds: ['root'] }, + { id: 'c', subject: 'c', message: 'c', parentIds: ['root'] }, + { id: 'detached', subject: 'detached', message: 'detached', parentIds: [] }, + { id: 'root', subject: 'root', message: 'root', parentIds: [] } + ] + }) + await act(async () => + root.render( + + + + ) + ) + expect(errors.mock.calls.filter(([message]) => String(message).includes('same key'))).toEqual([]) +}) + it('recovers silently after each later token expiry and ignores refreshes for other worktrees', async () => { let history: ReturnType function History() { @@ -597,3 +624,53 @@ it('applies checkbox choices once on picker close, with no choices returning to await openPicker() expect(document.querySelector('[role="checkbox"]')?.getAttribute('aria-checked')).toBe('false') }) + +it('keeps the confirm strip across tab switches, discarding it on navigation, filter change and close', async () => { + getHistory.mockResolvedValue(result) + const render = () => ( + + + + ) + await act(async () => root.render(render())) + const controller = gitGraphHistoryForTab(workspaceId) + await act(async () => controller.actions.request('revert', result.items[0]!)) + expect(container.textContent).toContain('Revert a?') + await act(async () => root.render(
Another tab
)) + await act(async () => root.render(render())) + expect(container.textContent).toContain('Revert a?') + await act(async () => controller.select('a')) + expect(container.textContent).not.toContain('Revert a?') + await act(async () => controller.actions.request('revert', result.items[0]!)) + await act(async () => + useAppStore.setState({ gitGraphFiltersByWorktree: { [workspaceId]: 'head' } }) + ) + expect(container.textContent).not.toContain('Revert a?') + await act(async () => controller.actions.request('revert', result.items[0]!)) + await act(async () => { + disposeGitGraphHistory(`${workspaceId}::git-graph`) + root.render(
Closed
) + }) + await act(async () => root.render(render())) + expect(container.textContent).not.toContain('Revert a?') +}) + +it('canonicalizes selected refs before requesting history', async () => { + let history: ReturnType + function History() { + history = useGitGraphHistory(workspaceId) + return null + } + getHistory.mockReset().mockResolvedValue({ ...result, refs: 'all' }) + await act(async () => root.render()) + getHistory.mockClear().mockResolvedValue({ + ...result, + refs: ['refs/heads/main', 'refs/heads/topic'], + resolvedRefs: ['refs/heads/main', 'refs/heads/topic'] + }) + await act(async () => { + history!.changeFilter(['refs/heads/topic', 'refs/heads/main', 'refs/heads/topic']) + await history!.controller.settledResult() + }) + expect(getHistory.mock.calls[0]?.[1].refs).toEqual(['refs/heads/main', 'refs/heads/topic']) +}) diff --git a/src/renderer/src/components/editor/git-graph/git-graph-tab-controllers.ts b/src/renderer/src/components/editor/git-graph/git-graph-tab-controllers.ts new file mode 100644 index 00000000000..7c8f789ed38 --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-tab-controllers.ts @@ -0,0 +1,17 @@ +import { GitGraphHistoryController } from './git-graph-history-controller' + +const controllers = new Map() +export function gitGraphHistoryForTab(worktreeId: string): GitGraphHistoryController { + const id = `${worktreeId}::git-graph` + let controller = controllers.get(id) + if (!controller) { + controller = new GitGraphHistoryController(worktreeId) + controllers.set(id, controller) + } + return controller +} + +export function disposeGitGraphHistory(tabId: string): void { + controllers.get(tabId)?.dispose() + controllers.delete(tabId) +} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-tab.tsx b/src/renderer/src/components/editor/git-graph/git-graph-tab.tsx index e65aa618f86..8e3abc51ba8 100644 --- a/src/renderer/src/components/editor/git-graph/git-graph-tab.tsx +++ b/src/renderer/src/components/editor/git-graph/git-graph-tab.tsx @@ -1,3 +1,8 @@ +import { useGitCommitActionsSupport } from '@/runtime/source-control/git/git-commit-actions-capability' +import { + GitGraphCommitActionControls, + GitGraphCommitConfirmStrip +} from './git-graph-commit-action-controls' import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useSidebarResize } from '@/hooks/useSidebarResize' import { useVirtualizer } from '@tanstack/react-virtual' @@ -27,6 +32,10 @@ export function GitGraphTab({ worktreeId }: { worktreeId: string }) { const intlLocale = useIntlLocale() const history = useGitGraphHistory(worktreeId) const { result, scope, selectedId, revealId, controller } = history + const commitActionSupport = useGitCommitActionsSupport( + scope?.target ?? null, + scope?.route.executionHostId ?? undefined + ) const workspaces = useGitGraphWorkspaces(scope) const rows = useMemo( () => @@ -213,6 +222,9 @@ export function GitGraphTab({ worktreeId }: { worktreeId: string }) { >
controller.actions.requestBranch(item, ref)} workspaces={workspaces} references={item.references ?? []} onSwitch={controller.switchToWorkspace} @@ -236,10 +248,14 @@ export function GitGraphTab({ worktreeId }: { worktreeId: string }) {
- + + + ) })} @@ -263,10 +279,18 @@ export function GitGraphTab({ worktreeId }: { worktreeId: string }) {
) @@ -112,6 +117,7 @@ export function ConflictSummaryCard({ // Paused rebase/merge/cherry-pick operations may need recovery even with no conflicted files. export function OperationBanner({ + children, conflictOperation, isRecoveryBusy = false, isAbortingOperation = false, @@ -121,6 +127,7 @@ export function OperationBanner({ sourceControlAiActionsVisible = false, onResolveWithAI }: { + children?: React.ReactNode conflictOperation: GitConflictOperation isRecoveryBusy?: boolean isAbortingOperation?: boolean @@ -147,10 +154,12 @@ export function OperationBanner({ 'auto.components.right.sidebar.source.control.conflict.status.cards.ffe53a1da6', 'Cherry-pick in progress' ) - : t( - 'auto.components.right.sidebar.source.control.conflict.status.cards.35eb76d323', - 'Operation in progress' - ) + : conflictOperation === 'revert' + ? t('gitGraph.revertInProgress', 'Revert in progress') + : t( + 'auto.components.right.sidebar.source.control.conflict.status.cards.35eb76d323', + 'Operation in progress' + ) const Icon = conflictOperation === 'rebase' ? GitPullRequestArrow : GitMerge const canCompleteMerge = conflictOperation === 'merge' && !!onCompleteMerge @@ -210,6 +219,7 @@ export function OperationBanner({ : t('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')} ) : null} + {children} ) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/content-status.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/content-status.tsx index 883c2a3afdb..a667e966fcd 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/content-status.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/content-status.tsx @@ -1,3 +1,4 @@ +import { SequencerRecoveryControls } from './sequencer-recovery-controls' import React from 'react' import { AlertTriangle } from 'lucide-react' import { useTranslate } from '@/i18n/use-translate' @@ -84,7 +85,15 @@ export function SourceControlContentStatus({ onAbortOperation={onAbortOperation} onResolveWithAI={onResolveWithAi} onReview={onReviewConflicts} - /> + > + {(conflictOperation === 'cherry-pick' || conflictOperation === 'revert') && ( + + )} + )} {unresolvedConflictCount === 0 && conflictOperation !== 'unknown' && ( @@ -98,7 +107,15 @@ export function SourceControlContentStatus({ onCompleteMerge={onCompleteMerge} sourceControlAiActionsVisible={sourceControlAiActionsVisible} onResolveWithAI={onResolveWithAi} - /> + > + {(conflictOperation === 'cherry-pick' || conflictOperation === 'revert') && ( + + )} + )} {repositoryHuge && ( diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/sequencer-recovery-controls.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/sequencer-recovery-controls.test.tsx new file mode 100644 index 00000000000..1e40bcf651c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/listing/sequencer-recovery-controls.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { SequencerRecoveryControls } from './sequencer-recovery-controls' +import { GIT_GRAPH_REFRESH_EVENT } from '@/lib/git-graph-tab' +import { local } from '@/components/editor/git-graph/git-graph-workspace.test-fixtures' +import type * as WorktreeGitCopy from '@/lib/worktree-git-copy' +import type { GitStatusResult } from '../../../../../../shared/git/status/git-status-types' + +const { status, continued } = vi.hoisted(() => ({ status: vi.fn(), continued: vi.fn() })) +vi.mock('@/runtime/source-control/git/runtime-git-commit-actions-client', () => ({ + sequencerContinueRuntimeGit: continued +})) +vi.mock('@/runtime/source-control/git/runtime-git-status-client', () => ({ + getRuntimeGitStatus: status +})) +vi.mock('@/runtime/source-control/git/git-commit-actions-capability', () => ({ + useGitCommitActionsSupport: () => ({ supported: true }), + gitCommitActionsSupported: async () => true +})) +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => async () => true +})) +vi.mock('./use-worktree-context', () => ({ + useSourceControlWorktreeContext: () => ({ activeWorktreeId: 'r::/repo', worktreePath: '/repo' }) +})) +vi.mock('@/lib/worktree-git-copy', async (importOriginal) => ({ + ...(await importOriginal()), + resolveWorktreeGitCopyNow: () => ({ + worktreeId: 'r::/repo', + hostId: useAppStore.getState().activeWorkspaceExecutionHostId, + transport: { kind: 'local' }, + connectionId: undefined + }) +})) + +it('fences late recovery status while invalidating the original graph after navigation', async () => { + const initial = useAppStore.getState() + const pending = Promise.withResolvers() + const fetching = Promise.withResolvers() + let accepted: GitStatusResult = { entries: [], head: 'host-A', conflictOperation: 'cherry-pick' } + const refreshed = Promise.withResolvers() + let requestedPublishTarget: unknown + let acceptedUpstream: unknown + useAppStore.setState({ + worktreesByRepo: { + r: [ + { ...local, pushTarget: { remoteName: 'publish-a', branchName: 'topic-a' } }, + { + ...local, + hostId: 'ssh:B', + pushTarget: { remoteName: 'publish-b', branchName: 'topic-b' } + } + ] + }, + activeWorktreeId: 'r::/repo', + activeWorkspaceExecutionHostId: 'local', + setGitStatus: (_id, result) => { + accepted = result + }, + updateWorktreeGitIdentity: () => {}, + fetchUpstreamStatus: async (_id, _path, _connectionId, pushTarget) => { + requestedPublishTarget = pushTarget + return { hasUpstream: true, ahead: 2, behind: 0 } + }, + setUpstreamStatus: (_id, upstream) => { + acceptedUpstream = upstream + refreshed.resolve() + } + }) + status.mockImplementation(() => { + fetching.resolve() + return pending.promise + }) + continued.mockResolvedValue(undefined) + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + let invalidatedWorktree: unknown + const onGraphRefresh = (event: Event) => { + if (event instanceof CustomEvent) { + invalidatedWorktree = event.detail + } + } + try { + await act(async () => + root.render( + + ) + ) + await act(async () => { + container.querySelector('button')!.click() + await fetching.promise + }) + await act(async () => useAppStore.setState({ activeWorkspaceExecutionHostId: 'ssh:B' })) + accepted = { entries: [], head: 'host-B', conflictOperation: 'revert' } + await act(async () => { + pending.resolve({ entries: [], head: 'host-A-clean', conflictOperation: 'unknown' }) + await pending.promise + }) + expect(accepted).toEqual({ entries: [], head: 'host-B', conflictOperation: 'revert' }) + + const recovering = Promise.withResolvers() + const started = Promise.withResolvers() + continued.mockImplementation(() => { + started.resolve() + return recovering.promise + }) + window.addEventListener(GIT_GRAPH_REFRESH_EVENT, onGraphRefresh) + await act(async () => { + container.querySelector('button')!.click() + await started.promise + }) + await act(async () => useAppStore.setState({ activeWorktreeId: 'other' })) + await act(async () => { + recovering.resolve() + await recovering.promise + }) + expect(invalidatedWorktree).toBe('r::/repo') + expect(accepted).toEqual({ entries: [], head: 'host-B', conflictOperation: 'revert' }) + + status.mockResolvedValue({ + entries: [], + head: 'host-B-clean', + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: true, ahead: 99, behind: 0 } + }) + continued.mockResolvedValue(undefined) + await act(async () => useAppStore.setState({ activeWorktreeId: 'r::/repo' })) + await act(async () => { + container.querySelector('button')!.click() + await refreshed.promise + }) + expect(requestedPublishTarget).toEqual({ remoteName: 'publish-b', branchName: 'topic-b' }) + expect(acceptedUpstream).toEqual({ hasUpstream: true, ahead: 2, behind: 0 }) + } finally { + window.removeEventListener(GIT_GRAPH_REFRESH_EVENT, onGraphRefresh) + await act(async () => root.unmount()) + container.remove() + useAppStore.setState(initial, true) + } +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/sequencer-recovery-controls.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/sequencer-recovery-controls.tsx new file mode 100644 index 00000000000..08990451968 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/listing/sequencer-recovery-controls.tsx @@ -0,0 +1,180 @@ +import { refreshGitStatusForWorktree } from '../sync/git-status-refresh' +import { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { useTranslate } from '@/i18n/use-translate' +import { + gitCommitActionsSupported, + useGitCommitActionsSupport +} from '@/runtime/source-control/git/git-commit-actions-capability' +import { + sequencerAbortRuntimeGit, + sequencerContinueRuntimeGit +} from '@/runtime/source-control/git/runtime-git-commit-actions-client' +import { GIT_GRAPH_REFRESH_EVENT } from '@/lib/git-graph-tab' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { useSourceControlWorktreeContext } from './use-worktree-context' +import type { GitCommitAction } from '../../../../../../shared/git/commands/git-commit-action' +import { useAppStore } from '@/store' +import { resolveWorktreeGitCopyNow, worktreeCopyPushTarget } from '@/lib/worktree-git-copy' + +async function runSequencerRecovery(run: () => Promise, refresh: () => void) { + try { + await run() + } finally { + refresh() + } +} + +export function SequencerRecoveryControls({ + worktreeId, + operation, + disabled +}: { + worktreeId: string + operation: GitCommitAction + disabled: boolean +}) { + const t = useTranslate() + const context = useSourceControlWorktreeContext() + const { supported, reason } = useGitCommitActionsSupport( + context.activeRepoTarget, + context.activeRepoExecutionHostId ?? undefined + ) + const operationLabel = + operation === 'revert' + ? t('gitGraph.revert', 'Revert') + : t('gitGraph.cherryPick', 'Cherry-pick') + const confirm = useConfirmationDialog() + const [busy, setBusy] = useState(false) + const navigation = useRef(0) + useEffect(() => { + const invalidate = () => { + navigation.current++ + } + const unsubscribe = useAppStore.subscribe((state, previous) => { + if ( + state.activeWorktreeId !== previous.activeWorktreeId || + state.activeWorkspaceExecutionHostId !== previous.activeWorkspaceExecutionHostId + ) { + invalidate() + } + }) + return () => { + invalidate() + unsubscribe() + } + }, []) + const run = async (action: 'abort' | 'continue') => { + const copy = resolveWorktreeGitCopyNow(worktreeId) + const target = copy?.transport + const path = context.worktreePath + if (busy || disabled || !copy || !target || !path || context.activeWorktreeId !== worktreeId) { + return + } + const generation = navigation.current + const shouldApply = () => + generation === navigation.current && + useAppStore.getState().activeWorktreeId === worktreeId && + JSON.stringify(resolveWorktreeGitCopyNow(worktreeId)) === JSON.stringify(copy) + if ( + action === 'abort' && + !(await confirm({ + title: t('gitGraph.abortOperationTitle', 'Abort {{operation}}?', { + operation: operationLabel + }), + description: t( + 'gitGraph.abortOperationDescription', + 'This cancels the operation in progress and can discard conflict resolutions made during it.' + ), + confirmLabel: t('gitGraph.abortOperation', 'Abort {{operation}}', { + operation: operationLabel + }), + confirmVariant: 'destructive' + })) + ) { + return + } + setBusy(true) + await runSequencerRecovery( + async () => { + try { + if (!(await gitCommitActionsSupported(target)) || !shouldApply()) { + return + } + const request = { + target, + worktreeId, + worktreePath: path, + connectionId: copy.connectionId + } + const result = await ( + action === 'abort' ? sequencerAbortRuntimeGit : sequencerContinueRuntimeGit + )(request, operation) + if (result?.outcome === 'empty') { + toast.message( + operation === 'revert' + ? t( + 'gitGraph.nothingToRevert', + 'Nothing to revert; the changes are already undone.' + ) + : t('gitGraph.alreadyApplied', 'Already applied, nothing to cherry-pick') + ) + } + } catch (error) { + toast.error( + extractIpcErrorMessage( + error, + t( + 'gitGraph.recoveryFailed', + 'Could not finish the operation. Refresh Source Control and try again.' + ) + ) + ) + } + }, + () => { + setBusy(false) + const state = useAppStore.getState() + if (shouldApply()) { + void refreshGitStatusForWorktree({ + worktreeId, + worktreePath: path, + target, + connectionId: copy.connectionId, + ownerHostId: copy.hostId, + pushTarget: worktreeCopyPushTarget(state, worktreeId, copy.hostId), + deps: state, + request: { shouldApply } + }).catch(() => {}) + } + window.dispatchEvent(new CustomEvent(GIT_GRAPH_REFRESH_EVENT, { detail: worktreeId })) + } + ) + } + return ( +
+ {reason &&

{reason}

} + + +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx index efd7c6ba881..b30c5e1ccee 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx @@ -1,3 +1,4 @@ +import { useGitCommitActionsSupport } from '@/runtime/source-control/git/git-commit-actions-capability' import { GitHistoryPanel } from '../sync/git-history-panel' import { shouldShowSourceControlCompareUnavailableCard } from './header-toolbar' import { shouldRenderCommitArea } from '../commit/component-gates' @@ -83,6 +84,9 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { visibleTreeRowsBySection } = model + const recovery = useGitCommitActionsSupport( + conflictOperation === 'revert' ? model.activeRepoTarget : null + ) const hasFilteredUncommittedEntries = displaySections.length > 0 const hasFilteredBranchEntries = filteredBranchEntries.length > 0 const showGenericEmptyState = @@ -129,9 +133,11 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { {/* Keep CommitArea through clean/mid-commit states (#1448); only active conflict operations replace the action surface. */} - {shouldRenderCommitArea(unresolvedConflicts.length, conflictOperation) && ( - - )} + {shouldRenderCommitArea( + unresolvedConflicts.length, + conflictOperation, + recovery.supported + ) && } {hasFilteredUncommittedEntries && ( void }): React.JSX.Element { const t = useTranslate() @@ -29,6 +31,7 @@ export function GitHistoryCommitContextMenu({ ) return ( + {children} {managedBrowserCreationEnabled ? ( onAction('open-remote', item)}> diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-graph-svg.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-graph-svg.tsx index 26756460c47..f3b9a3afdbe 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-graph-svg.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-graph-svg.tsx @@ -50,24 +50,21 @@ export function GitHistoryGraphSvg({ const circleIndex = getGitHistoryItemLaneIndex(viewModel) const circleColor = getGitHistoryItemColor(viewModel) - const paths: React.JSX.Element[] = [] + const paths: { d: string; color: GitHistoryGraphColorId }[] = [] let outputSwimlaneIndex = 0 for (let index = 0; index < inputSwimlanes.length; index += 1) { const color = inputSwimlanes[index]!.color if (inputSwimlanes[index]!.id === historyItem.id) { if (index !== circleIndex) { - paths.push( - - ) + paths.push({ + color, + d: [ + `M ${SWIMLANE_WIDTH * (index + 1)} 0`, + `A ${SWIMLANE_WIDTH} ${SWIMLANE_WIDTH} 0 0 1 ${SWIMLANE_WIDTH * index} ${SWIMLANE_NODE_Y}`, + `H ${SWIMLANE_WIDTH * (circleIndex + 1)}` + ].join(' ') + }) } else if (historyItem.parentIds.length > 0) { outputSwimlaneIndex += 1 } @@ -79,32 +76,26 @@ export function GitHistoryGraphSvg({ inputSwimlanes[index]!.id === outputSwimlanes[outputSwimlaneIndex]!.id ) { if (index === outputSwimlaneIndex) { - paths.push( - - ) + paths.push({ + color, + d: `M ${SWIMLANE_WIDTH * (index + 1)} 0 V ${SWIMLANE_HEIGHT}` + }) } else { - paths.push( - - ) + paths.push({ + color, + d: [ + `M ${SWIMLANE_WIDTH * (index + 1)} 0`, + 'V 6', + `A ${SWIMLANE_CURVE_RADIUS} ${SWIMLANE_CURVE_RADIUS} 0 0 1 ${ + SWIMLANE_WIDTH * (index + 1) - SWIMLANE_CURVE_RADIUS + } ${SWIMLANE_HEIGHT / 2}`, + `H ${SWIMLANE_WIDTH * (outputSwimlaneIndex + 1) + SWIMLANE_CURVE_RADIUS}`, + `A ${SWIMLANE_CURVE_RADIUS} ${SWIMLANE_CURVE_RADIUS} 0 0 0 ${ + SWIMLANE_WIDTH * (outputSwimlaneIndex + 1) + } ${SWIMLANE_HEIGHT / 2 + SWIMLANE_CURVE_RADIUS}`, + `V ${SWIMLANE_HEIGHT}` + ].join(' ') + }) } outputSwimlaneIndex += 1 } @@ -116,37 +107,28 @@ export function GitHistoryGraphSvg({ if (parentOutputIndex === -1) { continue } - paths.push( - - ) + paths.push({ + color: outputSwimlanes[parentOutputIndex]!.color, + d: [ + `M ${SWIMLANE_WIDTH * parentOutputIndex} ${SWIMLANE_HEIGHT / 2}`, + `A ${SWIMLANE_WIDTH} ${SWIMLANE_WIDTH} 0 0 1 ${SWIMLANE_WIDTH * (parentOutputIndex + 1)} ${SWIMLANE_HEIGHT}`, + `M ${SWIMLANE_WIDTH * parentOutputIndex} ${SWIMLANE_HEIGHT / 2}`, + `H ${SWIMLANE_WIDTH * (circleIndex + 1)}` + ].join(' ') + }) } if (inputIndex !== -1) { - paths.push( - - ) + paths.push({ + color: inputSwimlanes[inputIndex]!.color, + d: `M ${SWIMLANE_WIDTH * (circleIndex + 1)} 0 V ${SWIMLANE_HEIGHT / 2}` + }) } if (historyItem.parentIds.length > 0) { - paths.push( - - ) + paths.push({ + color: circleColor, + d: `M ${SWIMLANE_WIDTH * (circleIndex + 1)} ${SWIMLANE_HEIGHT / 2} V ${SWIMLANE_HEIGHT}` + }) } const cx = SWIMLANE_WIDTH * (circleIndex + 1) @@ -164,7 +146,9 @@ export function GitHistoryGraphSvg({ height={SWIMLANE_HEIGHT} viewBox={`0 0 ${width} ${SWIMLANE_HEIGHT}`} > - {paths} + {paths.map((path) => ( + + ))} {viewModel.kind === 'HEAD' && ( <> { + if (target.kind === 'environment') { + return runtimeEnvironmentSupportsCapability(target.environmentId, GIT_COMMIT_ACTIONS_CAPABILITY) + } + await ensureLocalRuntimeCapabilities() + return readLocalRuntimeCapabilitiesOrUnknown()?.includes(GIT_COMMIT_ACTIONS_CAPABILITY) === true +} + +export function useGitCommitActionsSupport(target: RuntimeClientTarget | null, hostName?: string) { + const t = useTranslate() + const state = useStructuredAgentSessionHostCapabilityState(target, GIT_COMMIT_ACTIONS_CAPABILITY) + const environmentName = useAppStore((store) => + target?.kind === 'environment' + ? store.runtimeEnvironments.find((environment) => environment.id === target.environmentId) + ?.name + : undefined + ) + return { + supported: state === 'supported', + reason: + state === 'unsupported' + ? t('gitGraph.updateActionsHost', 'Update Orca on {{host}} to act on commits', { + host: + environmentName ?? + (hostName === 'local' ? undefined : hostName) ?? + t('gitGraph.thisHost', 'this host') + }) + : undefined + } +} diff --git a/src/renderer/src/runtime/source-control/git/runtime-git-branch-client.ts b/src/renderer/src/runtime/source-control/git/runtime-git-branch-client.ts index 599b0cc38a0..aff3966e972 100644 --- a/src/renderer/src/runtime/source-control/git/runtime-git-branch-client.ts +++ b/src/renderer/src/runtime/source-control/git/runtime-git-branch-client.ts @@ -15,13 +15,16 @@ export async function switchRuntimeGitBranch( target: BranchSwitchTarget, hostLabel = context.target.kind === 'environment' ? context.target.environmentId - : translate('sourceControl.branchPicker.thisComputer', 'this computer') + : translate('sourceControl.branchPicker.thisComputer', 'this computer'), + beforeSend?: () => void ): Promise { try { - return await callRuntimeRpc(context.target, 'git.switchBranch', { - worktree: toRuntimeWorktreeSelector(context.worktreeId ?? ''), - target - }) + const params = { worktree: toRuntimeWorktreeSelector(context.worktreeId ?? ''), target } + return await (beforeSend + ? callRuntimeRpc(context.target, 'git.switchBranch', params, { + beforeSend + }) + : callRuntimeRpc(context.target, 'git.switchBranch', params)) } catch (error) { if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { throw new Error( diff --git a/src/renderer/src/runtime/source-control/git/runtime-git-commit-actions-client.ts b/src/renderer/src/runtime/source-control/git/runtime-git-commit-actions-client.ts new file mode 100644 index 00000000000..6fac55fd3f5 --- /dev/null +++ b/src/renderer/src/runtime/source-control/git/runtime-git-commit-actions-client.ts @@ -0,0 +1,115 @@ +import type { + GitCommitAction, + GitCommitActionResult +} from '../../../../../shared/git/commands/git-commit-action' +import { resolveLocalWorktreePath, type RuntimeGitContext } from './runtime-git-client-context' +import { callRuntimeRpc } from '../../rpc/calls/runtime-rpc-call' +import { toRuntimeWorktreeSelector } from '../../rpc/targets/runtime-worktree-selector' + +export async function createRuntimeGitBranch( + context: RuntimeGitContext, + name: string, + startPoint: string, + beforeSend?: () => void +): Promise { + if (context.target.kind === 'local' || !context.worktreeId) { + beforeSend?.() + return window.api.git.createBranch({ + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId, + name, + startPoint + }) + } + return callRuntimeRpc( + context.target, + 'git.createBranch', + { + worktree: toRuntimeWorktreeSelector(context.worktreeId), + name, + startPoint + }, + { timeoutMs: 30_000, beforeSend } + ) +} + +export async function cherryPickRuntimeGit( + context: RuntimeGitContext, + commitId: string, + beforeSend?: () => void +): Promise { + if (context.target.kind === 'local' || !context.worktreeId) { + beforeSend?.() + return window.api.git.cherryPick({ + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId, + commitId + }) + } + return callRuntimeRpc( + context.target, + 'git.cherryPick', + { worktree: toRuntimeWorktreeSelector(context.worktreeId), commitId }, + { timeoutMs: 30_000, beforeSend } + ) +} + +export async function revertRuntimeGit( + context: RuntimeGitContext, + commitId: string, + beforeSend?: () => void +): Promise { + if (context.target.kind === 'local' || !context.worktreeId) { + beforeSend?.() + return window.api.git.revert({ + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId, + commitId + }) + } + return callRuntimeRpc( + context.target, + 'git.revert', + { worktree: toRuntimeWorktreeSelector(context.worktreeId), commitId }, + { timeoutMs: 30_000, beforeSend } + ) +} + +export async function sequencerAbortRuntimeGit( + context: RuntimeGitContext, + operation: GitCommitAction +): Promise { + if (context.target.kind === 'local' || !context.worktreeId) { + return window.api.git.sequencerAbort({ + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId, + operation + }) + } + await callRuntimeRpc( + context.target, + 'git.sequencerAbort', + { worktree: toRuntimeWorktreeSelector(context.worktreeId), operation }, + { timeoutMs: 30_000 } + ) +} + +export async function sequencerContinueRuntimeGit( + context: RuntimeGitContext, + operation: GitCommitAction +): Promise { + if (context.target.kind === 'local' || !context.worktreeId) { + return window.api.git.sequencerContinue({ + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId, + operation + }) + } + const result = await callRuntimeRpc( + context.target, + 'git.sequencerContinue', + { worktree: toRuntimeWorktreeSelector(context.worktreeId), operation }, + { timeoutMs: 30_000 } + ) + return typeof result === 'object' && result !== null && 'outcome' in result ? result : undefined +} diff --git a/src/renderer/src/web/preload-api/web-git-api.ts b/src/renderer/src/web/preload-api/web-git-api.ts index 24ad865c1e7..84ae7eefd54 100644 --- a/src/renderer/src/web/preload-api/web-git-api.ts +++ b/src/renderer/src/web/preload-api/web-git-api.ts @@ -1,3 +1,4 @@ +import { createGitCommitActionsApi } from './web-git-commit-actions-api' import { createGitHistoryApi } from './web-git-history-api' import type { PreloadApi } from '../../../../preload/api-types' import { callAbortableRuntimeEnvironment } from '../../runtime/rpc/calls/abortable-runtime-environment-call' @@ -220,6 +221,7 @@ export function createGitApi(): NonNullable['git']> { message }) }, + ...createGitCommitActionsApi(), completeMerge: async ({ worktreePath }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) return callRuntimeResult('git.completeMerge', { diff --git a/src/renderer/src/web/preload-api/web-git-commit-actions-api.ts b/src/renderer/src/web/preload-api/web-git-commit-actions-api.ts new file mode 100644 index 00000000000..9fa45bf67c0 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-git-commit-actions-api.ts @@ -0,0 +1,55 @@ +import type { GitCommitActionResult } from '../../../../shared/git/commands/git-commit-action' +import type { PreloadApi } from '../../../../preload/api-types' +import { callRuntimeResult } from './web-runtime-calls' +import { toRuntimeWorktreeSelector } from '../../runtime/rpc/targets/runtime-worktree-selector' +import { resolveRuntimeWorktreeByPath } from './web-runtime-worktree-catalog' + +export function createGitCommitActionsApi(): Pick< + PreloadApi['git'], + 'createBranch' | 'cherryPick' | 'revert' | 'sequencerAbort' | 'sequencerContinue' +> { + return { + createBranch: async ({ worktreePath, name, startPoint }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.createBranch', { + worktree: toRuntimeWorktreeSelector(worktree.id), + name, + startPoint + }) + }, + sequencerContinue: async ({ worktreePath, operation }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + const result = await callRuntimeResult( + 'git.sequencerContinue', + { + worktree: toRuntimeWorktreeSelector(worktree.id), + operation + } + ) + return typeof result === 'object' && result !== null && 'outcome' in result + ? result + : undefined + }, + sequencerAbort: async ({ worktreePath, operation }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.sequencerAbort', { + worktree: toRuntimeWorktreeSelector(worktree.id), + operation + }) + }, + cherryPick: async ({ worktreePath, commitId }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.cherryPick', { + worktree: toRuntimeWorktreeSelector(worktree.id), + commitId + }) + }, + revert: async ({ worktreePath, commitId }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.revert', { + worktree: toRuntimeWorktreeSelector(worktree.id), + commitId + }) + } + } +} diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index ce1a7ffc1ff..d52375805cc 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -1,3 +1,4 @@ +import { GIT_REVERT_CONFLICT_CLIENT_CAPABILITY } from './git/commands/git-commit-action' import { GIT_GRAPH_TAB_CAPABILITY } from './source-control/runtime/git-graph-capabilities' import { STRUCTURED_AGENT_SESSION_RENDERER_CLIENT_CAPABILITIES } from './agent-session/structured-agent-session-renderer-client-capabilities' import { TERMINAL_IMAGE_VIEWER_CAPABILITY } from './terminal/images/terminal-image-viewer-capability' @@ -17,6 +18,7 @@ import { // host still requires the separate authenticated browser-client lease. export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ GIT_GRAPH_TAB_CAPABILITY, + GIT_REVERT_CONFLICT_CLIENT_CAPABILITY, STRUCTURED_CHAT_START_TELEMETRY_CLIENT_CAPABILITY, ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, TERMINAL_IMAGE_VIEWER_CAPABILITY, diff --git a/src/shared/git/commands/git-commit-action.ts b/src/shared/git/commands/git-commit-action.ts new file mode 100644 index 00000000000..801422ebd33 --- /dev/null +++ b/src/shared/git/commands/git-commit-action.ts @@ -0,0 +1,10 @@ +export type GitCommitAction = 'cherry-pick' | 'revert' + +export type GitCommitActionResult = { + outcome: 'landed' | 'conflicted' | 'refused' | 'landed-with-warning' | 'empty' + commitId?: string + branch?: string + message?: string +} + +export const GIT_REVERT_CONFLICT_CLIENT_CAPABILITY = 'git.conflict-operation.revert.v1' as const diff --git a/src/shared/git/status/git-status-types.ts b/src/shared/git/status/git-status-types.ts index 62356894789..9bf3d41dfca 100644 --- a/src/shared/git/status/git-status-types.ts +++ b/src/shared/git/status/git-status-types.ts @@ -11,7 +11,7 @@ export type GitConflictKind = export type GitConflictResolutionStatus = 'unresolved' | 'resolved_locally' export type GitConflictStatusSource = 'git' | 'session' -export type GitConflictOperation = 'merge' | 'rebase' | 'cherry-pick' | 'unknown' +export type GitConflictOperation = 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'unknown' export type GitSubmoduleStatus = { commitChanged: boolean trackedChanges: boolean diff --git a/src/shared/rpc-contract/git-params.ts b/src/shared/rpc-contract/git-params.ts index 542dad33c55..c19177984e2 100644 --- a/src/shared/rpc-contract/git-params.ts +++ b/src/shared/rpc-contract/git-params.ts @@ -82,6 +82,17 @@ export const GitCommitCompare = WorktreeSelector.extend({ .pipe(FullGitObjectId) }) +export const GitSequencer = WorktreeSelector.extend({ + operation: z.enum(['cherry-pick', 'revert']) +}) + +export const GitCreateBranch = WorktreeSelector.extend({ + name: z.string().min(1), + startPoint: FullGitObjectId +}) + +export const GitCommitAction = WorktreeSelector.extend({ commitId: FullGitObjectId }) + export const GitHistoryRefName = z .string() .refine(isGitHistoryRefName, 'Expected full branch or tag ref names') @@ -254,6 +265,7 @@ export const GitSwitchBranch = WorktreeSelector.extend({ }) export const GitCheckout = WorktreeSelector.extend({ + detached: z.boolean().optional(), branch: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) @@ -264,7 +276,10 @@ export const GitCheckout = WorktreeSelector.extend({ // Why: never let a branch arg be parsed as a git flag (arg injection). .refine((value) => !value.startsWith('-'), 'Branch must not start with -') ) -}) +}).refine( + (params) => !params.detached || FullGitObjectId.safeParse(params.branch).success, + 'Expected a full git object id for detached checkout' +) export const GitRemoteFileUrl = WorktreeSelector.extend({ relativePath: z diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index a0afc86363c..352082d1a04 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -275,8 +275,10 @@ import { GitCheckIgnored, GitCheckout, GitCommit, + GitCommitAction, GitCommitCompare, GitCommitDiff, + GitCreateBranch, GitDiff, GitDiscoverCommitMessageModels, GitFilePath, @@ -290,6 +292,7 @@ import { GitRemoteCommitUrl, GitRemoteFileUrl, GitResolveConflict, + GitSequencer, GitStatusParams, GitSubmoduleStatus, GitSwitchBranch, @@ -1085,11 +1088,13 @@ export const RPC_PARAMS_BY_METHOD = { 'git.cancelGeneratePullRequestFields': WorktreeSelectorOfGitParams, 'git.checkIgnored': GitCheckIgnored, 'git.checkout': GitCheckout, + 'git.cherryPick': GitCommitAction, 'git.commit': GitCommit, 'git.commitCompare': GitCommitCompare, 'git.commitDiff': GitCommitDiff, 'git.completeMerge': WorktreeSelectorOfGitParams, 'git.conflictOperation': WorktreeSelectorOfGitParams, + 'git.createBranch': GitCreateBranch, 'git.diff': GitDiff, 'git.discard': GitFilePath, 'git.discoverCommitMessageModels': GitDiscoverCommitMessageModels, @@ -1108,6 +1113,9 @@ export const RPC_PARAMS_BY_METHOD = { 'git.remoteCommitUrl': GitRemoteCommitUrl, 'git.remoteFileUrl': GitRemoteFileUrl, 'git.resolveConflict': GitResolveConflict, + 'git.revert': GitCommitAction, + 'git.sequencerAbort': GitSequencer, + 'git.sequencerContinue': GitSequencer, 'git.stage': GitFilePath, 'git.status': GitStatusParams, 'git.submoduleStatus': GitSubmoduleStatus, diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index d653d22a03e..51a7d488806 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -171,7 +171,7 @@ export type RuntimeSpeechSetupState = { models: RuntimeSpeechModelSummary[] } -export type RuntimeGitSwitchResult = { ok: true; branch: string; warning?: string } +export type RuntimeGitSwitchResult = { ok: true; branch: string; head?: string; warning?: string } export type RuntimeGitCheckoutResult = { ok: true diff --git a/src/shared/source-control/runtime/git-graph-capabilities.ts b/src/shared/source-control/runtime/git-graph-capabilities.ts index 98b86e8edc1..e7f5e622dc2 100644 --- a/src/shared/source-control/runtime/git-graph-capabilities.ts +++ b/src/shared/source-control/runtime/git-graph-capabilities.ts @@ -1,2 +1,3 @@ export const GIT_HISTORY_REFS_CAPABILITY = 'git.history-refs.v1' as const export const GIT_GRAPH_TAB_CAPABILITY = 'layout.git-graph-tab.v1' as const +export const GIT_COMMIT_ACTIONS_CAPABILITY = 'git.commit-actions.v1' as const diff --git a/src/shared/source-control/runtime/source-control-runtime-capabilities.ts b/src/shared/source-control/runtime/source-control-runtime-capabilities.ts index 9f23d54367d..4c758f2c53c 100644 --- a/src/shared/source-control/runtime/source-control-runtime-capabilities.ts +++ b/src/shared/source-control/runtime/source-control-runtime-capabilities.ts @@ -1,4 +1,8 @@ -import { GIT_HISTORY_REFS_CAPABILITY, GIT_GRAPH_TAB_CAPABILITY } from './git-graph-capabilities' +import { + GIT_HISTORY_REFS_CAPABILITY, + GIT_GRAPH_TAB_CAPABILITY, + GIT_COMMIT_ACTIONS_CAPABILITY +} from './git-graph-capabilities' export const GITHUB_COMPLETE_PR_CHECK_OBSERVATION_CAPABILITY = 'github.complete-pr-check-observation.v1' as const export const GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY = 'github.markPRReadyForReview' as const @@ -27,6 +31,7 @@ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = export const SOURCE_CONTROL_RUNTIME_CAPABILITIES = [ GIT_HISTORY_REFS_CAPABILITY, GIT_GRAPH_TAB_CAPABILITY, + GIT_COMMIT_ACTIONS_CAPABILITY, GITHUB_COMPLETE_PR_CHECK_OBSERVATION_CAPABILITY, GITHUB_EXACT_WORK_ITEM_TARGET_RUNTIME_CAPABILITY, ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, diff --git a/src/shared/web-runtime-client-capabilities.ts b/src/shared/web-runtime-client-capabilities.ts index f8f470b9de2..ba87fc09830 100644 --- a/src/shared/web-runtime-client-capabilities.ts +++ b/src/shared/web-runtime-client-capabilities.ts @@ -1,3 +1,4 @@ +import { GIT_REVERT_CONFLICT_CLIENT_CAPABILITY } from './git/commands/git-commit-action' import { GIT_GRAPH_TAB_CAPABILITY } from './source-control/runtime/git-graph-capabilities' import { STRUCTURED_AGENT_SESSION_RENDERER_CLIENT_CAPABILITIES } from './agent-session/structured-agent-session-renderer-client-capabilities' import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from './agent-session/agent-session-runtime-capabilities' @@ -20,6 +21,7 @@ import { /** What the browser client advertises in its handshake with the host it is paired to. */ export const WEB_RUNTIME_CLIENT_CAPABILITIES = [ GIT_GRAPH_TAB_CAPABILITY, + GIT_REVERT_CONFLICT_CLIENT_CAPABILITY, // The desktop's chat, over the same `agentSession.*` calls. ...STRUCTURED_AGENT_SESSION_RENDERER_CLIENT_CAPABILITIES, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,