From deefd503dadee79ef43b821cc5ee8714c61e9efd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:56:06 -0700 Subject: [PATCH] Add sequencer continue/skip actions and branch identity display Rebase/merge operations leave HEAD detached, which confused users and broke the branch identity display. Users also lacked UI controls to continue past resolved conflicts or skip patches, forcing them to CLI. This adds both the capability and clearer context for the workflow. --- .orca-reports/rebase-banner-actions-report.md | 116 +++++++ .../use-mobile-conflict-sequencer-runners.ts | 44 +++ .../use-mobile-source-control-runners.ts | 16 +- src/main/git/sequencer-actions.test.ts | 60 ++++ src/main/git/sequencer-actions.ts | 42 +++ src/main/git/source-control/status-read.ts | 13 + .../git/status-conflict-operations.test.ts | 31 +- .../filesystem-branch-compare-diff.test.ts | 4 + ...lesystem-commit-message-generation.test.ts | 4 + ...tem-commit-message-model-discovery.test.ts | 4 + .../ipc/filesystem-download-transfers.test.ts | 4 + .../filesystem-git-commit-dispatch.test.ts | 4 + .../ipc/filesystem-git-status-staging.test.ts | 29 ++ ...lesystem-markdown-document-listing.test.ts | 4 + ...stem-pull-request-field-generation.test.ts | 4 + src/main/ipc/filesystem-test-harness.ts | 10 + src/main/ipc/filesystem.test.ts | 4 + src/main/ipc/filesystem.ts | 61 ++++ src/main/providers/git-provider-contract.ts | 3 + .../providers/ssh-git-provider-merge.test.ts | 21 ++ src/main/providers/ssh-git-provider.ts | 31 ++ .../orca-runtime-git-sequencer.test.ts | 71 ++++ src/main/runtime/orca-runtime-git.ts | 6 + src/main/runtime/orca-runtime.ts | 6 + src/main/runtime/rpc/methods/git.test.ts | 9 + src/main/runtime/rpc/methods/git.ts | 15 + .../runtime/runtime-git-api-contract.test.ts | 3 + src/main/runtime/runtime-git-sync-commands.ts | 43 +++ ...me-rpc-mobile-method-allowlist-fixtures.ts | 35 +- ...untime-rpc-mobile-method-allowlist.test.ts | 8 +- .../runtime-rpc-mobile-method-allowlist.ts | 3 + src/preload/api/git-operation-api.ts | 3 + src/preload/index.ts | 6 + src/relay/git-handler-operation-context.ts | 1 + src/relay/git-handler-status-ops.ts | 15 +- src/relay/git-handler.test.ts | 83 +++++ src/relay/git-handler.ts | 31 +- .../right-sidebar/CommitArea.test.tsx | 61 +++- ...ource-control-branch-context-stats.test.ts | 20 ++ ...e-control-header-toolbar-identity.test.tsx | 46 ++- .../ai/conflict-handoff-gate.test.ts | 19 ++ .../ai/conflict-handoff-gate.ts | 13 + .../right-sidebar/source-control/ai/use-ai.ts | 5 +- .../source-control/commit/use-commit-flows.ts | 19 +- .../listing/conflict-status-cards.tsx | 305 ++++++++++-------- .../source-control/listing/content-status.tsx | 70 ++-- .../listing/operation-banner-actions.tsx | 142 ++++++++ .../operation-banner-transition.test.tsx | 92 ++++++ .../listing/operation-banner.test.tsx | 168 ++++++++++ .../listing/use-worktree-context.ts | 30 +- .../panel/branch-context-row.tsx | 71 +--- .../panel/branch-context-stats.ts | 9 +- .../panel/head-identity-chip.tsx | 131 ++++++++ .../source-control/panel/header-toolbar.tsx | 10 +- .../source-control/panel/panel-content.tsx | 6 + .../source-control/panel/panel-ready.tsx | 2 + .../panel/use-worktree-operation-state.ts | 8 + .../source-control/sync/action-error.ts | 6 +- .../sync/use-conflict-advance.test.tsx | 111 +++++++ .../sync/use-conflict-advance.ts | 128 ++++++++ src/renderer/src/i18n/locales/en.json | 37 ++- src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - .../lib/worktree-git-identity-display.test.ts | 53 +++ .../src/lib/worktree-git-identity-display.ts | 45 +++ .../runtime-git-client-api-contract.test.ts | 3 + .../runtime/runtime-git-client-merge.test.ts | 66 +++- .../src/runtime/runtime-git-client.ts | 6 + .../src/runtime/runtime-git-sync-client.ts | 51 +++ .../editor-git-operation-progress.test.ts | 108 +++++++ .../editor/actions/git-status-actions.ts | 35 ++ .../editor/git/operation-progress-state.ts | 41 +++ .../slices/editor/types/editor-git-slice.ts | 4 + .../session/worktree-identity-rename-state.ts | 1 + .../teardown/remove-worktree-store-cleanup.ts | 3 + .../teardown/worktree-purge-state.ts | 1 + src/shared/git-rebase-progress.test.ts | 186 +++++++++++ src/shared/git-rebase-progress.ts | 133 ++++++++ src/shared/git-sequencer-editor-env.test.ts | 33 ++ src/shared/git-sequencer-editor-env.ts | 19 ++ src/shared/git-status-types.ts | 26 ++ 83 files changed, 2913 insertions(+), 261 deletions(-) create mode 100644 .orca-reports/rebase-banner-actions-report.md create mode 100644 mobile/src/source-control/use-mobile-conflict-sequencer-runners.ts create mode 100644 src/main/git/sequencer-actions.test.ts create mode 100644 src/main/git/sequencer-actions.ts create mode 100644 src/main/runtime/orca-runtime-git-sequencer.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control/ai/conflict-handoff-gate.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control/ai/conflict-handoff-gate.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/operation-banner-actions.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/operation-banner-transition.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/operation-banner.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control/panel/head-identity-chip.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.ts create mode 100644 src/renderer/src/store/slices/editor-git-operation-progress.test.ts create mode 100644 src/renderer/src/store/slices/editor/git/operation-progress-state.ts create mode 100644 src/shared/git-rebase-progress.test.ts create mode 100644 src/shared/git-rebase-progress.ts create mode 100644 src/shared/git-sequencer-editor-env.test.ts create mode 100644 src/shared/git-sequencer-editor-env.ts diff --git a/.orca-reports/rebase-banner-actions-report.md b/.orca-reports/rebase-banner-actions-report.md new file mode 100644 index 00000000000..ea6bcc70658 --- /dev/null +++ b/.orca-reports/rebase-banner-actions-report.md @@ -0,0 +1,116 @@ +# Rebase/merge banner — remaining scope, completed + +All three items implemented, uncommitted in `rebase-banner-actions`. 56 files modified, 21 new. +The pre-existing work (Resolve with AI on OperationBanner, `canSendConflictsToAgent`) was kept intact. + +## 1. Branch identity while an operation runs + +The header read `Detached HEAD · 285883d → origin/main ↑1` mid-rebase. Now it names the branch. + +- `src/renderer/src/lib/worktree-git-identity-display.ts` — new `getWorktreeGitOperationIdentityDisplay()` + and an `operation` variant on `WorktreeGitIdentityDisplay`. Kept as structured data (branchName, + operation, shortHead, head) so labels stay translatable at the render site. The eight existing + callers of the plain function are untouched. +- `panel/head-identity-chip.tsx` (new) — extracted `HeadIdentity` + `resolveHeadFlowLabel` out of + `branch-context-row.tsx`, which was at ~395 of its 400-line budget. Renders `triage-e2e · rebasing` + with the qualifier in amber; the raw SHA moved into the tooltip. +- `panel/branch-context-stats.ts` — `buildSourceControlBranchContextStats` takes + `operationInProgress` and returns `[]`. `conflictOperation` is threaded + panel-ready → header-toolbar → branch-context-row. The base ref itself still shows; only the + counts measured against a transient mid-rebase commit go away. + +## 2. Step meter and commit subject + +- `src/shared/git-status-types.ts` — `GitOperationProgress` (headName, onto, currentStep, + totalSteps, commitSubject, stoppedBy) as one optional field on `GitStatusResult`. + Additive optional field = Rule 1 of remote-wire-compatibility. Absent means unknown throughout; + nothing is ever defaulted to 0. +- `src/shared/git-rebase-progress.ts` (new) — one reader used by BOTH producers + (`src/main/git/status.ts` and `src/relay/git-handler-status-ops.ts`), which previously had + duplicated `resolveGitDir`/`detectConflictOperation`. The relay executes on the remote host, so + plain fs reads work for SSH — no new RPC. Read runs concurrently with the status stream. +- Formats verified against real repos on git 2.52 before coding: + - `rebase-merge/`: msgnum, end, head-name, onto, message (first line), done (last line). + - `rebase-apply/` (am mode): next, last, head-name, onto, final-commit. No done file, no edit/break. + - `done` last-line command word distinguishes edit / break / pick — both the newer + `pick # subject` and the older no-`#` form parse. +- Store: `gitOperationProgressByWorktree` slice, pruned on worktree removal/purge/rename. + A capped ("too many changes") snapshot never reads the state dir, so it keeps the last known + progress instead of blanking a live meter. + +## 3. Continue and Skip + +Five new RPCs mirroring abortMerge/abortRebase end to end: `git.continueMerge`, +`git.continueRebase`, `git.continueCherryPick`, `git.skipRebase`, `git.skipCherryPick`. +Chain: runtime-git-client → runtime-rpc allowlist + rpc/methods/git → git-provider-contract → +`src/main/git/sequencer-actions.ts` (new; NOT added to status.ts, which is at its line budget) → +ssh-git-provider → relay/git-handler → orca-runtime-git + orca-runtime → preload (api + index) → +main/ipc/filesystem → web-preload-api → mobile runners. + +### The find worth knowing about +`git rebase|merge|cherry-pick --continue` **opens the user's commit-message editor**. With a normal +`core.editor=vim` the subprocess hangs forever. Verified on git 2.52: +- unset editor + hostile GIT_EDITOR → hangs; +- `git -c core.editor=true rebase --continue` → **still hangs** (the GIT_EDITOR env var beats + `-c core.editor`); +- `GIT_EDITOR=true git rebase --continue` → completes. +Nothing in the codebase set GIT_EDITOR; `nonInteractiveGitEnv` guards credential prompts only. +`src/shared/git-sequencer-editor-env.ts` (new) sets it, and forwards it via WSLENV on win32 because +spawn env does not cross the wsl.exe boundary. The relay's private `git()` gained a +`suppressEditor` opt for the same reason (an SSH host can export its own GIT_EDITOR). +Guarded by unit tests on argv+env and by relay integration tests that drive real git through a +conflicted rebase/merge with a hostile `GIT_EDITOR=false` — those were confirmed to fail without +the fix. + +Git versions checked against the 2.25 baseline: `merge --continue` 2.12, `cherry-pick --skip` 2.22, +the rest older. All at or below baseline, so no GitCapabilityCache probe and no fallback — recorded +in a comment so it is not re-litigated. + +### UI +- `listing/operation-stop-reason.ts` (new) — pure derivation of why git stopped and which action leads. +- `listing/operation-progress-meter.tsx` (new), `listing/operation-banner-actions.tsx` (new). +- Layout per the mockup: one full-width primary, then Resolve with AI, then a quiet ghost footer row + pairing Skip and Abort. Not four stacked full-width buttons. +- Primary by stop reason: conflicts → Resolve with AI; ready → Continue; empty patch → Skip; + edit/break pause → Continue. Falls back to the next *offered* action when the preferred one has no + handler. +- Continue needs no confirmation. Skip confirms (destructive), because it drops a commit. +- Both disabled while `isAbortingOperation` **or** `isAdvancingOperation` is true. +- `use-conflict-advance.ts` (new) mirrors `use-conflict-abort.ts`; refreshes status afterwards + because Continue can land straight in a new conflict. + +### Mid-flight transition +`ConflictSummaryCard` and `OperationBanner` were split into `OperationCardShell` + body components. +`content-status.tsx` renders one shell and swaps only the body, so the card box stays the same DOM +node across the swap. Asserted directly: the test holds the node reference and checks identity after +rerender. My first attempt kept two sibling components and the node *was* replaced — the test caught +it, and the shell was made genuinely stable rather than the assertion weakened. + +## Two real bugs the tests caught in my own work +- Continue rendered `variant="default"` even when it was not the primary action, producing two + competing default buttons. Fixed so only the primary slot reads as default. +- Continue was offered while conflicts were unresolved, where `git --continue` is guaranteed to fail + ("You must edit all merge conflicts"). It is now withheld entirely in that state, matching the + mockup; Skip is promoted when AI is unavailable. + +## Verification (actual output) +- `npx tsc --noEmit -p config/tsconfig.tc.web.json` → exit 0 +- `npx tsc --noEmit -p config/tsconfig.node.json` → exit 0 +- `npx oxlint src mobile config tests` → exit 0 +- `pnpm run check:max-lines-ratchet` → OK, no new bypasses +- `npx oxfmt --check` on all changed files → all correctly formatted +- localization catalog + coverage → exit 0 (18 new keys added via `pnpm run sync:localization-catalog`) +- renderer/shared/preload/store/runtime: **1099 files, 10 683 tests passed** +- main/relay: **1031 files, 11 570 tests passed** + +### Two things that do NOT pass, both pre-existing and unrelated +1. `src/main/pty/posix-pty-process-groups.integration.test.ts` — `spawnSync ps ENOBUFS`. + Confirmed pre-existing: it fails identically with all of my work stashed. +2. `mobile/` vitest cannot load any test file — `failed to resolve "extends":"expo/tsconfig.base.json"`. + `mobile/node_modules` does not exist in this worktree and expo is not installed at the root; + `mobile/tsconfig.json` is untouched by this change. Mobile files are covered by oxlint and by the + node tsc project, both clean, but the mobile suite itself could not be executed here. + +## Notes +- Not committed, not pushed, no PR. +- `rebase-banner-mockup.html` deleted (the brief left the choice; the design is now in code and tests). diff --git a/mobile/src/source-control/use-mobile-conflict-sequencer-runners.ts b/mobile/src/source-control/use-mobile-conflict-sequencer-runners.ts new file mode 100644 index 00000000000..b4f85b6c1e5 --- /dev/null +++ b/mobile/src/source-control/use-mobile-conflict-sequencer-runners.ts @@ -0,0 +1,44 @@ +import { useCallback } from 'react' + +// RPC method per conflict-banner control, keyed by the in-progress git operation. +const ABORT_METHODS: Record = { + merge: 'git.abortMerge', + rebase: 'git.abortRebase' +} + +const CONTINUE_METHODS: Record = { + merge: 'git.continueMerge', + rebase: 'git.continueRebase', + 'cherry-pick': 'git.continueCherryPick' +} + +type RunGitAction = ( + actionId: string, + method: string, + params: Record +) => Promise + +/** Abort / Continue controls on the conflict banner. */ +export function useMobileConflictSequencerRunners(runGitAction: RunGitAction) { + const runForOperation = useCallback( + async (prefix: string, methods: Record, operation: string) => { + const method = methods[operation] + if (!method) { + return + } + await runGitAction(`${prefix}-${operation}`, method, {}) + }, + [runGitAction] + ) + + return { + abortConflictOperation: useCallback( + (operation: string) => runForOperation('abort', ABORT_METHODS, operation), + [runForOperation] + ), + continueConflictOperation: useCallback( + (operation: string) => runForOperation('continue', CONTINUE_METHODS, operation), + [runForOperation] + ) + } +} diff --git a/mobile/src/source-control/use-mobile-source-control-runners.ts b/mobile/src/source-control/use-mobile-source-control-runners.ts index 026a706a5f9..a466ffaa2fe 100644 --- a/mobile/src/source-control/use-mobile-source-control-runners.ts +++ b/mobile/src/source-control/use-mobile-source-control-runners.ts @@ -13,6 +13,7 @@ import type { MobileCommitFailureRecovery, RecordMobileCommitFailure } from './mobile-commit-failure-recovery' +import { useMobileConflictSequencerRunners } from './use-mobile-conflict-sequencer-runners' type GitStep = { method: string; params?: Record } type SendGitRequest = (method: string, params?: Record) => Promise @@ -292,18 +293,7 @@ export function useMobileSourceControlRunners(params: Params) { setShowActionSheet }) - // Abort an in-progress merge/rebase from the conflict banner. - const abortConflictOperation = useCallback( - async (operation: string) => { - const method = - operation === 'merge' ? 'git.abortMerge' : operation === 'rebase' ? 'git.abortRebase' : null - if (!method) { - return - } - await runGitAction(`abort-${operation}`, method, {}) - }, - [runGitAction] - ) + const conflictRunners = useMobileConflictSequencerRunners(runGitAction) return { runGitAction, @@ -316,7 +306,7 @@ export function useMobileSourceControlRunners(params: Params) { openBranchPicker, openHistory, checkoutBranch, - abortConflictOperation, + ...conflictRunners, ...actionSheetRunners } } diff --git a/src/main/git/sequencer-actions.test.ts b/src/main/git/sequencer-actions.test.ts new file mode 100644 index 00000000000..6b0a9f43c90 --- /dev/null +++ b/src/main/git/sequencer-actions.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createGitRunnerModuleMock } from './status-test-harness' + +const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, gitStreamOptionsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileAsyncBufferMock: vi.fn(), + gitStreamOptionsMock: vi.fn() + }) +) + +vi.mock('./runner', () => + createGitRunnerModuleMock({ + gitExecFileAsyncMock, + gitExecFileAsyncBufferMock, + gitStreamOptionsMock + }) +) + +import { continueCherryPick, continueMerge, continueRebase } from './sequencer-actions' + +type SequencerAction = (worktreePath: string, options?: { wslDistro?: string }) => Promise + +const CASES: readonly [string, SequencerAction, string[]][] = [ + ['continueMerge', continueMerge, ['merge', '--continue']], + ['continueRebase', continueRebase, ['rebase', '--continue']], + ['continueCherryPick', continueCherryPick, ['cherry-pick', '--continue']] +] + +describe('git sequencer actions', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + }) + + it.each(CASES)('%s runs the matching git command in the worktree', async (_name, run, args) => { + await run('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + args, + expect.objectContaining({ cwd: '/repo' }) + ) + }) + + // Regression guard: without GIT_EDITOR the `--continue` child waits forever on the commit editor. + it.each(CASES)('%s suppresses the commit-message editor', async (_name, run) => { + await run('/repo') + + expect(gitExecFileAsyncMock.mock.calls[0][1].env.GIT_EDITOR).toBe('true') + }) + + it('forwards runtime options such as the WSL distro', async () => { + await continueRebase('/repo', { wslDistro: 'Ubuntu' }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rebase', '--continue'], + expect.objectContaining({ cwd: '/repo', wslDistro: 'Ubuntu' }) + ) + }) +}) diff --git a/src/main/git/sequencer-actions.ts b/src/main/git/sequencer-actions.ts new file mode 100644 index 00000000000..02751a1654a --- /dev/null +++ b/src/main/git/sequencer-actions.ts @@ -0,0 +1,42 @@ +import { editorSuppressedGitEnv } from '../../shared/git-sequencer-editor-env' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +import { gitExecFileAsync } from './runner' +import { runWithGitReadCacheInvalidation } from './status' + +// Why: every subcommand here predates the Git 2.25 baseline (`merge --continue` 2.12, +// the rest older), so no capability probe or fallback is needed. +async function runSequencerAction( + args: readonly [string, string], + worktreePath: string, + options: GitRuntimeOptions +): Promise { + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync([...args], { + ...gitOptionsForWorktree(worktreePath, options), + // Why: `--continue` opens the commit-message editor and would hang with no terminal to close it. + env: editorSuppressedGitEnv() + }) + ) +} + +export async function continueMerge( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { + await runSequencerAction(['merge', '--continue'], worktreePath, options) +} + +export async function continueRebase( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { + await runSequencerAction(['rebase', '--continue'], worktreePath, options) +} + +export async function continueCherryPick( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { + await runSequencerAction(['cherry-pick', '--continue'], worktreePath, options) +} diff --git a/src/main/git/source-control/status-read.ts b/src/main/git/source-control/status-read.ts index ae1fbbec8df..972743688d9 100644 --- a/src/main/git/source-control/status-read.ts +++ b/src/main/git/source-control/status-read.ts @@ -12,11 +12,13 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../../../shared/git-status-line-stats-cache' +import { readGitRebaseProgress } from '../../../shared/git-rebase-progress' import { gitOptionalLocksDisabledEnv, gitStreamStdout } from '../runner' import { findExistingWorktreeSymlinkPaths } from '../worktree-symlink-detection' import type { GetStatusOptions } from './get-status-options' import { statusReadLeaseOwner } from './git-read-cache-invalidation' import { detectConflictOperation } from './git-conflict-operation' +import { resolveGitDir } from './resolve-git-dir' import { parseUnmergedEntry } from './status-conflict-entries' import { getEffectiveUpstreamStatusCacheKey } from './effective-upstream-status-cache' import { @@ -113,6 +115,14 @@ async function runGetStatus( // Why: detectConflictOperation and git status are independent, so run them concurrently to save I/O latency. const conflictPromise = detectConflictOperation(worktreePath) + // Why: only sequencer operations have state on disk, so a clean repo reads nothing. + const operationProgressPromise = conflictPromise + .then(async (operation) => + operation === 'rebase' || operation === 'cherry-pick' + ? await readGitRebaseProgress(await resolveGitDir(worktreePath)) + : undefined + ) + .catch(() => undefined) // Why: core.quotePath=false keeps non-ASCII paths as raw UTF-8, not octal escapes, so entry.path is readable and lookups match. const statusArgs = [ '-c', @@ -242,11 +252,14 @@ async function runGetStatus( throw error } + const operationProgress = await operationProgressPromise + return { entries, conflictOperation, head, branch, + ...(operationProgress ? { operationProgress } : {}), ...(options.includeIgnored ? { ignoredPaths: parser.ignoredPaths } : {}), ...(branchLineTotal ? { branchLineTotal } : {}), ...(didHitLimit ? { didHitLimit: true, statusLength: parser.statusLength } : {}), diff --git a/src/main/git/status-conflict-operations.test.ts b/src/main/git/status-conflict-operations.test.ts index 5b61932578f..7185c03a86d 100644 --- a/src/main/git/status-conflict-operations.test.ts +++ b/src/main/git/status-conflict-operations.test.ts @@ -54,7 +54,7 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => }) ) -import { abortMerge, abortRebase, detectConflictOperation } from './status' +import { abortMerge, abortRebase, detectConflictOperation, getStatus } from './status' describe('abortMerge', () => { beforeEach(() => { @@ -148,3 +148,32 @@ describe('detectConflictOperation', () => { await expect(detectConflictOperation('/repo')).resolves.toBe('unknown') }) }) + +describe('getStatus operationProgress', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitStreamOptionsMock.mockReset() + readFileMock.mockReset() + statMock.mockReset() + existsSyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + }) + + it('omits operationProgress when a sequencer operation has no rebase state on disk', async () => { + // Only `.git` itself resolves; every rebase-merge/rebase-apply read misses. + readFileMock.mockImplementation(async (target: string) => + target.endsWith('.git') + ? 'gitdir: /repo/.git/worktrees/feature\n' + : Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + ) + existsSyncMock.mockImplementation((target: string) => target.endsWith('CHERRY_PICK_HEAD')) + + const result = await getStatus('/repo') + + expect(result.conflictOperation).toBe('cherry-pick') + expect(result.operationProgress).toBeUndefined() + expect('operationProgress' in result).toBe(false) + // The reader ran and degraded — it did not skip the state directory. + expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('rebase-merge'), 'utf-8') + }) +}) diff --git a/src/main/ipc/filesystem-branch-compare-diff.test.ts b/src/main/ipc/filesystem-branch-compare-diff.test.ts index bb0669ec697..1643b1814ff 100644 --- a/src/main/ipc/filesystem-branch-compare-diff.test.ts +++ b/src/main/ipc/filesystem-branch-compare-diff.test.ts @@ -31,6 +31,10 @@ vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock ) +vi.mock( + '../git/sequencer-actions', + async () => (await import('./filesystem-test-harness')).gitSequencerActionsModuleMock +) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index 8719c8d0f71..70efe84365c 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -33,6 +33,10 @@ vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock ) +vi.mock( + '../git/sequencer-actions', + async () => (await import('./filesystem-test-harness')).gitSequencerActionsModuleMock +) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock diff --git a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts index 73154bddab5..3124dcb2932 100644 --- a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts +++ b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts @@ -30,6 +30,10 @@ vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock ) +vi.mock( + '../git/sequencer-actions', + async () => (await import('./filesystem-test-harness')).gitSequencerActionsModuleMock +) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock diff --git a/src/main/ipc/filesystem-download-transfers.test.ts b/src/main/ipc/filesystem-download-transfers.test.ts index 9d031ff4d39..9eec3dde45b 100644 --- a/src/main/ipc/filesystem-download-transfers.test.ts +++ b/src/main/ipc/filesystem-download-transfers.test.ts @@ -34,6 +34,10 @@ vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock ) +vi.mock( + '../git/sequencer-actions', + async () => (await import('./filesystem-test-harness')).gitSequencerActionsModuleMock +) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index 66b728b49a2..f2742cda364 100644 --- a/src/main/ipc/filesystem-git-commit-dispatch.test.ts +++ b/src/main/ipc/filesystem-git-commit-dispatch.test.ts @@ -27,6 +27,10 @@ vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock ) +vi.mock( + '../git/sequencer-actions', + async () => (await import('./filesystem-test-harness')).gitSequencerActionsModuleMock +) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock diff --git a/src/main/ipc/filesystem-git-status-staging.test.ts b/src/main/ipc/filesystem-git-status-staging.test.ts index 5cc1b4e9540..e7600fef4d4 100644 --- a/src/main/ipc/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem-git-status-staging.test.ts @@ -9,6 +9,9 @@ import { getStatusMock, abortMergeMock, abortRebaseMock, + continueMergeMock, + continueRebaseMock, + continueCherryPickMock, stageFileMock, bulkStageFilesMock, bulkUnstageFilesMock, @@ -38,6 +41,10 @@ vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock ) +vi.mock( + '../git/sequencer-actions', + async () => (await import('./filesystem-test-harness')).gitSequencerActionsModuleMock +) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock @@ -420,6 +427,28 @@ describe('registerFilesystemHandlers', () => { expect(sshProvider.abortRebase).toHaveBeenCalledWith('/remote/repo') }) + it.each([ + ['git:continueMerge', 'continueMerge', continueMergeMock], + ['git:continueRebase', 'continueRebase', continueRebaseMock], + ['git:continueCherryPick', 'continueCherryPick', continueCherryPickMock] + ])('routes %s through local and SSH git providers', async (channel, method, localMock) => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + localMock.mockResolvedValue(undefined) + const sshProvider = { [method]: vi.fn().mockResolvedValue(undefined) } + getSshGitProviderMock.mockReturnValue(sshProvider) + + registerFilesystemHandlers(store as never) + + await handlers.get(channel)!(null, { worktreePath: WORKTREE_FEATURE_PATH }) + await handlers.get(channel)!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1' + }) + + expect(localMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {}) + expect(sshProvider[method]).toHaveBeenCalledWith('/remote/repo') + }) + it('rejects git file paths that escape the selected worktree', async () => { registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem-markdown-document-listing.test.ts b/src/main/ipc/filesystem-markdown-document-listing.test.ts index d70d46c48e1..b76b4abff4f 100644 --- a/src/main/ipc/filesystem-markdown-document-listing.test.ts +++ b/src/main/ipc/filesystem-markdown-document-listing.test.ts @@ -28,6 +28,10 @@ vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock ) +vi.mock( + '../git/sequencer-actions', + async () => (await import('./filesystem-test-harness')).gitSequencerActionsModuleMock +) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock diff --git a/src/main/ipc/filesystem-pull-request-field-generation.test.ts b/src/main/ipc/filesystem-pull-request-field-generation.test.ts index b12e5d2b010..1684747e0b1 100644 --- a/src/main/ipc/filesystem-pull-request-field-generation.test.ts +++ b/src/main/ipc/filesystem-pull-request-field-generation.test.ts @@ -32,6 +32,10 @@ vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock ) +vi.mock( + '../git/sequencer-actions', + async () => (await import('./filesystem-test-harness')).gitSequencerActionsModuleMock +) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index 78f85f1dbcf..5b12a6e7269 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -30,6 +30,9 @@ export const commitChangesMock: IpcMock = vi.fn() export const getStatusMock: IpcMock = vi.fn() export const abortMergeMock: IpcMock = vi.fn() export const abortRebaseMock: IpcMock = vi.fn() +export const continueMergeMock: IpcMock = vi.fn() +export const continueRebaseMock: IpcMock = vi.fn() +export const continueCherryPickMock: IpcMock = vi.fn() export const getDiffMock: IpcMock = vi.fn() export const getBranchCompareMock: IpcMock = vi.fn() export const getBranchDiffMock: IpcMock = vi.fn() @@ -102,6 +105,12 @@ export const gitStatusModuleMock = { discardChanges: discardChangesMock } +export const gitSequencerActionsModuleMock = { + continueMerge: continueMergeMock, + continueRebase: continueRebaseMock, + continueCherryPick: continueCherryPickMock +} + export const gitIgnoredPathsMock = { checkIgnoredPaths: checkIgnoredPathsMock } export const gitWorktreeMock = { @@ -220,6 +229,7 @@ const ALL_MOCKS = [ crashBreadcrumbMock, folderPromotionMock, gitStatusModuleMock, + gitSequencerActionsModuleMock, gitIgnoredPathsMock, gitWorktreeMock, sshFilesystemDispatchMock, diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index fe1568dbf72..d6d4e3ab725 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -38,6 +38,10 @@ vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock ) +vi.mock( + '../git/sequencer-actions', + async () => (await import('./filesystem-test-harness')).gitSequencerActionsModuleMock +) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 41b8b8e8da7..2d661bd6688 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -60,6 +60,7 @@ import { getCommitCompare, getCommitDiff } from '../git/status' +import { continueCherryPick, continueMerge, continueRebase } from '../git/sequencer-actions' import { getHistory } from '../git/history' import { cancelGenerateCommitMessageLocal, @@ -1389,6 +1390,66 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:continueMerge', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.continueMerge(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await continueMerge(worktreePath, gitOptions) + } + ) + + ipcMain.handle( + 'git:continueRebase', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.continueRebase(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await continueRebase(worktreePath, gitOptions) + } + ) + + ipcMain.handle( + 'git:continueCherryPick', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.continueCherryPick(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await continueCherryPick(worktreePath, gitOptions) + } + ) + ipcMain.handle( 'git:diff', async ( diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts index e6edb9f1c75..02fdd240109 100644 --- a/src/main/providers/git-provider-contract.ts +++ b/src/main/providers/git-provider-contract.ts @@ -45,6 +45,9 @@ export type IGitProvider = { detectConflictOperation(worktreePath: string): Promise abortMerge(worktreePath: string): Promise abortRebase(worktreePath: string): Promise + continueMerge(worktreePath: string): Promise + continueRebase(worktreePath: string): Promise + continueCherryPick(worktreePath: string): Promise checkoutBranch(worktreePath: string, branch: string): Promise listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }> getBranchCompare( diff --git a/src/main/providers/ssh-git-provider-merge.test.ts b/src/main/providers/ssh-git-provider-merge.test.ts index 7c7d0aa0717..d25fc5111a6 100644 --- a/src/main/providers/ssh-git-provider-merge.test.ts +++ b/src/main/providers/ssh-git-provider-merge.test.ts @@ -19,6 +19,27 @@ describe('SshGitProvider merge operations', () => { }) }) + it.each([ + ['continueMerge', 'git.continueMerge'], + ['continueRebase', 'git.continueRebase'], + ['continueCherryPick', 'git.continueCherryPick'] + ] as const)('%s sends the %s request', async (method, rpcMethod) => { + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } + const provider = new SshGitProvider('conn-1', mux as never) + + await provider[method]('/home/user/repo') + + expect(mux.request).toHaveBeenCalledWith(rpcMethod, { + worktreePath: '/home/user/repo' + }) + }) + it('abortRebase sends git.abortRebase request', async () => { const mux = { request: vi.fn().mockResolvedValue(undefined), diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 5fd475a0698..2e678fb39b0 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -61,6 +61,37 @@ export class SshGitProvider extends SshGitWorktreeProvider implements IGitProvid return result as { stdout: string; stderr: string } } + async continueMerge(worktreePath: string): Promise { + await this.sequencerRequest('git.continueMerge', worktreePath, 'continue a merge') + } + + async continueRebase(worktreePath: string): Promise { + await this.sequencerRequest('git.continueRebase', worktreePath, 'continue a rebase') + } + + async continueCherryPick(worktreePath: string): Promise { + await this.sequencerRequest('git.continueCherryPick', worktreePath, 'continue a cherry-pick') + } + + private async sequencerRequest( + method: string, + worktreePath: string, + action: string + ): Promise { + try { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request(method, { worktreePath }) + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + `This SSH host is running an older Orca relay that cannot ${action}. Reconnect to deploy the latest relay, then try again.` + ) + } + throw error + } + } + async clone( args: string[], cwd: string, diff --git a/src/main/runtime/orca-runtime-git-sequencer.test.ts b/src/main/runtime/orca-runtime-git-sequencer.test.ts new file mode 100644 index 00000000000..e1306bd80ee --- /dev/null +++ b/src/main/runtime/orca-runtime-git-sequencer.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' + +const mocks = vi.hoisted(() => ({ + continueMerge: vi.fn(), + continueRebase: vi.fn(), + continueCherryPick: vi.fn(), + getSshGitProvider: vi.fn() +})) + +vi.mock('../git/sequencer-actions', () => ({ + continueMerge: mocks.continueMerge, + continueRebase: mocks.continueRebase, + continueCherryPick: mocks.continueCherryPick +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: mocks.getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable' +})) + +const CASES = [ + ['continueRuntimeGitMerge', 'continueMerge'], + ['continueRuntimeGitRebase', 'continueRebase'], + ['continueRuntimeGitCherryPick', 'continueCherryPick'] +] as const + +function makeCommands(connectionId?: string): RuntimeGitCommands { + const worktree = { id: 'wt-1', repoId: 'repo-1', path: '/repo' } as ResolvedRuntimeGitWorktree + return new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree, + ...(connectionId ? { connectionId } : {}) + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) +} + +describe('RuntimeGitCommands sequencer continue', () => { + beforeEach(() => { + for (const mock of Object.values(mocks)) { + mock.mockReset() + } + }) + + it.each(CASES)('%s runs %s against the resolved worktree', async (command, gitFn) => { + mocks[gitFn].mockResolvedValue(undefined) + + await expect(makeCommands()[command]('id:wt-1')).resolves.toEqual({ ok: true }) + + expect(mocks[gitFn]).toHaveBeenCalledWith('/repo', {}) + }) + + it.each(CASES)('%s routes through the SSH git provider', async (command, gitFn) => { + const provider = { [gitFn]: vi.fn().mockResolvedValue(undefined) } + mocks.getSshGitProvider.mockReturnValue(provider) + + await expect(makeCommands('conn-1')[command]('id:wt-1')).resolves.toEqual({ ok: true }) + + expect(provider[gitFn]).toHaveBeenCalledWith('/repo') + expect(mocks[gitFn]).not.toHaveBeenCalled() + }) + + it.each(CASES)('%s fails when the SSH git provider is missing', async (command, gitFn) => { + mocks.getSshGitProvider.mockReturnValue(null) + + await expect(makeCommands('conn-1')[command]('id:wt-1')).rejects.toThrow() + expect(mocks[gitFn]).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index b5a5041fd19..3d0b537af5f 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -27,6 +27,9 @@ export class RuntimeGitCommands { readonly getRuntimeGitRemoteCommitUrl: RuntimeGitDiffCommands['getRuntimeGitRemoteCommitUrl'] readonly abortRuntimeGitMerge: RuntimeGitSyncCommands['abortRuntimeGitMerge'] readonly abortRuntimeGitRebase: RuntimeGitSyncCommands['abortRuntimeGitRebase'] + readonly continueRuntimeGitMerge: RuntimeGitSyncCommands['continueRuntimeGitMerge'] + readonly continueRuntimeGitRebase: RuntimeGitSyncCommands['continueRuntimeGitRebase'] + readonly continueRuntimeGitCherryPick: RuntimeGitSyncCommands['continueRuntimeGitCherryPick'] readonly getRuntimeGitUpstreamStatus: RuntimeGitSyncCommands['getRuntimeGitUpstreamStatus'] readonly fetchRuntimeGit: RuntimeGitSyncCommands['fetchRuntimeGit'] readonly syncRuntimeGitForkDefaultBranch: RuntimeGitSyncCommands['syncRuntimeGitForkDefaultBranch'] @@ -70,6 +73,9 @@ export class RuntimeGitCommands { this.getRuntimeGitRemoteCommitUrl = diff.getRuntimeGitRemoteCommitUrl.bind(diff) this.abortRuntimeGitMerge = sync.abortRuntimeGitMerge.bind(sync) this.abortRuntimeGitRebase = sync.abortRuntimeGitRebase.bind(sync) + this.continueRuntimeGitMerge = sync.continueRuntimeGitMerge.bind(sync) + this.continueRuntimeGitRebase = sync.continueRuntimeGitRebase.bind(sync) + this.continueRuntimeGitCherryPick = sync.continueRuntimeGitCherryPick.bind(sync) this.getRuntimeGitUpstreamStatus = sync.getRuntimeGitUpstreamStatus.bind(sync) this.fetchRuntimeGit = sync.fetchRuntimeGit.bind(sync) this.syncRuntimeGitForkDefaultBranch = sync.syncRuntimeGitForkDefaultBranch.bind(sync) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0d5ccf2be6e..32bfd812d0d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11610,6 +11610,12 @@ export class OrcaRuntimeService { this.gitCommands.abortRuntimeGitMerge.bind(this.gitCommands) abortRuntimeGitRebase: RuntimeGitCommands['abortRuntimeGitRebase'] = this.gitCommands.abortRuntimeGitRebase.bind(this.gitCommands) + continueRuntimeGitMerge: RuntimeGitCommands['continueRuntimeGitMerge'] = + this.gitCommands.continueRuntimeGitMerge.bind(this.gitCommands) + continueRuntimeGitRebase: RuntimeGitCommands['continueRuntimeGitRebase'] = + this.gitCommands.continueRuntimeGitRebase.bind(this.gitCommands) + continueRuntimeGitCherryPick: RuntimeGitCommands['continueRuntimeGitCherryPick'] = + this.gitCommands.continueRuntimeGitCherryPick.bind(this.gitCommands) checkoutRuntimeGitBranch: RuntimeGitCommands['checkoutRuntimeGitBranch'] = this.gitCommands.checkoutRuntimeGitBranch.bind(this.gitCommands) listRuntimeGitLocalBranches: RuntimeGitCommands['listRuntimeGitLocalBranches'] = diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 34ec308bfb4..ecb6f392599 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -306,6 +306,9 @@ describe('git RPC methods', () => { cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }), abortRuntimeGitMerge: vi.fn().mockResolvedValue({ ok: true }), abortRuntimeGitRebase: vi.fn().mockResolvedValue({ ok: true }), + continueRuntimeGitMerge: vi.fn().mockResolvedValue({ ok: true }), + continueRuntimeGitRebase: vi.fn().mockResolvedValue({ ok: true }), + continueRuntimeGitCherryPick: vi.fn().mockResolvedValue({ ok: true }), pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }), getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3'), getRuntimeGitRemoteCommitUrl: vi.fn().mockResolvedValue('https://example.com/commit/abc') @@ -329,6 +332,9 @@ describe('git RPC methods', () => { ) await dispatcher.dispatch(makeRequest('git.abortMerge', { worktree: 'id:wt-1' })) await dispatcher.dispatch(makeRequest('git.abortRebase', { worktree: 'id:wt-1' })) + for (const method of ['git.continueMerge', 'git.continueRebase', 'git.continueCherryPick']) { + await dispatcher.dispatch(makeRequest(method, { worktree: 'id:wt-1' })) + } await dispatcher.dispatch( makeRequest('git.push', { worktree: 'id:wt-1', @@ -358,6 +364,9 @@ describe('git RPC methods', () => { expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1') expect(runtime.abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1') expect(runtime.abortRuntimeGitRebase).toHaveBeenCalledWith('id:wt-1') + expect(runtime.continueRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1') + expect(runtime.continueRuntimeGitRebase).toHaveBeenCalledWith('id:wt-1') + expect(runtime.continueRuntimeGitCherryPick).toHaveBeenCalledWith('id:wt-1') expect(runtime.pushRuntimeGit).toHaveBeenCalledWith( 'id:wt-1', true, diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index ddfbe7bf273..4822852fd13 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -93,6 +93,21 @@ export const GIT_METHODS: RpcMethod[] = [ params: WorktreeSelector, handler: async (params, { runtime }) => runtime.abortRuntimeGitRebase(params.worktree) }), + defineMethod({ + name: 'git.continueMerge', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.continueRuntimeGitMerge(params.worktree) + }), + defineMethod({ + name: 'git.continueRebase', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.continueRuntimeGitRebase(params.worktree) + }), + defineMethod({ + name: 'git.continueCherryPick', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.continueRuntimeGitCherryPick(params.worktree) + }), defineMethod({ name: 'git.checkout', params: GitCheckout, diff --git a/src/main/runtime/runtime-git-api-contract.test.ts b/src/main/runtime/runtime-git-api-contract.test.ts index 29c468758f8..7f4b8c21373 100644 --- a/src/main/runtime/runtime-git-api-contract.test.ts +++ b/src/main/runtime/runtime-git-api-contract.test.ts @@ -11,6 +11,9 @@ const RPC_TO_RUNTIME_COMMAND = { 'git.conflictOperation': 'getRuntimeGitConflictOperation', 'git.abortMerge': 'abortRuntimeGitMerge', 'git.abortRebase': 'abortRuntimeGitRebase', + 'git.continueMerge': 'continueRuntimeGitMerge', + 'git.continueRebase': 'continueRuntimeGitRebase', + 'git.continueCherryPick': 'continueRuntimeGitCherryPick', 'git.checkout': 'checkoutRuntimeGitBranch', 'git.localBranches': 'listRuntimeGitLocalBranches', 'git.diff': 'getRuntimeGitDiff', diff --git a/src/main/runtime/runtime-git-sync-commands.ts b/src/main/runtime/runtime-git-sync-commands.ts index 7cc892bac75..3529f864b0d 100644 --- a/src/main/runtime/runtime-git-sync-commands.ts +++ b/src/main/runtime/runtime-git-sync-commands.ts @@ -4,6 +4,7 @@ import type { GitPushTarget } from '../../shared/worktree/types' import { gitSyncForkDefaultBranch } from '../git/fork-sync' import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' import { abortMerge, abortRebase, commitChanges } from '../git/status' +import { continueCherryPick, continueMerge, continueRebase } from '../git/sequencer-actions' import { getUpstreamStatus } from '../git/upstream' import { getSshGitProvider, @@ -48,6 +49,48 @@ export class RuntimeGitSyncCommands { return { ok: true } } + async continueRuntimeGitMerge(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.continueMerge(target.worktree.path) + return { ok: true } + } + await continueMerge(target.worktree.path, localGitOptionsForTarget(target)) + return { ok: true } + } + + async continueRuntimeGitRebase(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.continueRebase(target.worktree.path) + return { ok: true } + } + await continueRebase(target.worktree.path, localGitOptionsForTarget(target)) + return { ok: true } + } + + async continueRuntimeGitCherryPick(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.continueCherryPick(target.worktree.path) + return { ok: true } + } + await continueCherryPick(target.worktree.path, localGitOptionsForTarget(target)) + return { ok: true } + } + async getRuntimeGitUpstreamStatus( worktreeSelector: string, pushTarget?: GitPushTarget diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts index 7a97745764d..174d94bf94a 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts @@ -1,4 +1,4 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import type { Mock } from 'vitest' import type { OrcaRuntimeService } from './orca-runtime' @@ -33,6 +33,9 @@ export function createMobileRpcSurfaceRuntime() { const rebaseRuntimeGitFromBase: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) const abortRuntimeGitMerge: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) const abortRuntimeGitRebase: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) + const continueRuntimeGitMerge: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) + const continueRuntimeGitRebase: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) + const continueRuntimeGitCherryPick: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) const bulkStageRuntimeGitPaths: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) const bulkUnstageRuntimeGitPaths: MobileRpcMock = vi.fn().mockResolvedValue({ ok: true }) const getRuntimeGitDiff: MobileRpcMock = vi.fn().mockResolvedValue({ @@ -130,6 +133,9 @@ export function createMobileRpcSurfaceRuntime() { rebaseRuntimeGitFromBase, abortRuntimeGitMerge, abortRuntimeGitRebase, + continueRuntimeGitMerge, + continueRuntimeGitRebase, + continueRuntimeGitCherryPick, bulkStageRuntimeGitPaths, bulkUnstageRuntimeGitPaths, getRuntimeGitDiff, @@ -180,3 +186,30 @@ export function createMobileRpcSurfaceRuntime() { expectedCodexResetScope } } + +// Sequencer RPCs share one shape (worktree in, { ok: true } out), so the +// allowlist suite drives and asserts them as a set instead of line by line. +export const MOBILE_SEQUENCER_RPC_CASES = [ + ['git.continueMerge', 'continueRuntimeGitMerge'], + ['git.continueRebase', 'continueRuntimeGitRebase'], + ['git.continueCherryPick', 'continueRuntimeGitCherryPick'] +] as const + +export async function dispatchMobileSequencerRpcs( + dispatch: (request: Record) => Promise, + deviceToken: string +): Promise { + for (const [method] of MOBILE_SEQUENCER_RPC_CASES) { + await dispatch({ id: `req_${method}`, method, deviceToken, params: { worktree: 'id:wt-1' } }) + } +} + +export function expectMobileSequencerRpcsDispatched( + replies: Record[], + mocks: ReturnType['mocks'] +): void { + for (const [method, runtimeMethod] of MOBILE_SEQUENCER_RPC_CASES) { + expect(replies).toContainEqual(expect.objectContaining({ id: `req_${method}`, ok: true })) + expect(mocks[runtimeMethod]).toHaveBeenCalledWith('id:wt-1') + } +} diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts index 88f4aefb186..9d6643a2f65 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it } from 'vitest' import { OrcaRuntimeRpcServer } from './runtime-rpc' import { remoteRpcContentBudget } from '../../shared/remote-rpc-content-budget' import { DeviceRegistry } from './device-registry' -import { createMobileRpcSurfaceRuntime } from './runtime-rpc-mobile-method-allowlist-fixtures' +import { + createMobileRpcSurfaceRuntime, + dispatchMobileSequencerRpcs, + expectMobileSequencerRpcsDispatched +} from './runtime-rpc-mobile-method-allowlist-fixtures' describe('OrcaRuntimeRpcServer', () => { it('limits mobile-scoped WebSocket tokens to the mobile RPC surface', async () => { @@ -394,6 +398,7 @@ describe('OrcaRuntimeRpcServer', () => { deviceToken: mobile.token, params: { worktree: 'id:wt-1' } }) + await dispatchMobileSequencerRpcs(dispatch, mobile.token) await dispatch({ id: 'req_git_bulk_unstage', method: 'git.bulkUnstage', @@ -584,6 +589,7 @@ describe('OrcaRuntimeRpcServer', () => { expect(replies).toContainEqual( expect.objectContaining({ id: 'req_git_abort_rebase', ok: true }) ) + expectMobileSequencerRpcsDispatched(replies, mocks) expect(replies).toContainEqual( expect.objectContaining({ id: 'req_git_bulk_unstage', ok: true }) ) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index 461ec02ba69..e48cdacd45d 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -60,6 +60,9 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'git.commit', 'git.commitCompare', 'git.commitDiff', + 'git.continueCherryPick', + 'git.continueMerge', + 'git.continueRebase', 'git.discard', 'git.discoverCommitMessageModels', 'git.diff', diff --git a/src/preload/api/git-operation-api.ts b/src/preload/api/git-operation-api.ts index d71f95c8537..540d998b4f4 100644 --- a/src/preload/api/git-operation-api.ts +++ b/src/preload/api/git-operation-api.ts @@ -9,6 +9,9 @@ export type GitOperationApi = { appendGitignore: (args: { worktreePath: string; folderName: string }) => Promise abortMerge: (args: { worktreePath: string; connectionId?: string }) => Promise abortRebase: (args: { worktreePath: string; connectionId?: string }) => Promise + continueMerge: (args: { worktreePath: string; connectionId?: string }) => Promise + continueRebase: (args: { worktreePath: string; connectionId?: string }) => Promise + continueCherryPick: (args: { worktreePath: string; connectionId?: string }) => Promise fetch: (args: { worktreePath: string connectionId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index d1c2c358cc2..83382ec7bfd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3641,6 +3641,12 @@ const api = { ipcRenderer.invoke('git:abortMerge', args), abortRebase: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:abortRebase', args), + continueMerge: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:continueMerge', args), + continueRebase: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:continueRebase', args), + continueCherryPick: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:continueCherryPick', args), diff: (args: { worktreePath: string filePath: string diff --git a/src/relay/git-handler-operation-context.ts b/src/relay/git-handler-operation-context.ts index 85d2d908097..54f11ad7df8 100644 --- a/src/relay/git-handler-operation-context.ts +++ b/src/relay/git-handler-operation-context.ts @@ -11,6 +11,7 @@ export type GitHandlerCommandOptions = { disableOptionalLocks?: boolean signal?: AbortSignal nonInteractive?: boolean + suppressEditor?: boolean stdin?: string timeout?: number terminationBarrier?: boolean diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 4abebc3a080..d18df874dfa 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -8,7 +8,8 @@ import { readFile } from 'node:fs/promises' import { parseUnmergedEntry } from './git-handler-utils' import type { GitExec } from './git-handler-ops' import type { RelayGitStreamExec } from './git-stdout-stream' -import type { GitUpstreamStatus } from '../shared/git-status-types' +import type { GitOperationProgress, GitUpstreamStatus } from '../shared/git-status-types' +import { readGitRebaseProgress } from '../shared/git-rebase-progress' import { StatusPorcelainParser } from '../shared/git-status-porcelain-parser' import { splitRemoteBranchName } from '../shared/git-effective-upstream' import { collectGitStatusLineStatInputs } from '../shared/git-status-line-stat-inputs' @@ -81,6 +82,7 @@ export async function getStatusOp( didHitLimit?: boolean statusLength?: number branchLineTotal?: GitBranchLineTotal + operationProgress?: GitOperationProgress }> { const worktreePath = params.worktreePath as string const lineStatsCacheKey = `relay\0${worktreePath}` @@ -94,6 +96,14 @@ export async function getStatusOp( // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) const conflictPromise = detectConflictOperation(worktreePath) + // Why: only the sequencer operations have state on disk, so chain the read off the probe — a clean repo reads nothing. + const operationProgressPromise = conflictPromise + .then(async (operation) => + operation === 'rebase' || operation === 'cherry-pick' + ? await readGitRebaseProgress(await resolveGitDir(worktreePath)) + : undefined + ) + .catch(() => undefined) // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. const statusArgs = [ '-c', @@ -231,12 +241,15 @@ export async function getStatusOp( throw error } + const operationProgress = await operationProgressPromise + return { entries, conflictOperation, head, branch, upstreamStatus, + ...(operationProgress ? { operationProgress } : {}), ...(includeIgnored ? { ignoredPaths } : {}), ...(didHitLimit ? { didHitLimit: true, statusLength } : {}), ...(branchLineTotal ? { branchLineTotal } : {}) diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index d8156741a3f..5d00bf2320b 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -50,6 +50,9 @@ describe('GitHandler', () => { expect(methods).toContain('git.bulkUnstage') expect(methods).toContain('git.abortMerge') expect(methods).toContain('git.abortRebase') + expect(methods).toContain('git.continueMerge') + expect(methods).toContain('git.continueRebase') + expect(methods).toContain('git.continueCherryPick') expect(methods).toContain('git.checkout') expect(methods).toContain('git.localBranches') expect(methods).toContain('git.discard') @@ -160,6 +163,86 @@ describe('GitHandler', () => { }) }) + describe('sequencer continue', () => { + // Why: a hostile ambient editor is the regression this suite guards — `--continue` + // must never consult it (an unsuppressed real editor would hang the relay forever). + beforeEach(() => { + vi.stubEnv('GIT_EDITOR', 'false') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + // Two branches touching the same line, left on `feature` with `base` diverged. + const seedDivergedBranches = (): string => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n') + gitCommit(tmpDir, 'initial') + const baseBranch = execFileSync('git', ['branch', '--show-current'], { + cwd: tmpDir, + encoding: 'utf-8', + stdio: 'pipe' + }).trim() + execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n') + gitCommit(tmpDir, 'feature change') + execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n') + gitCommit(tmpDir, 'main change') + return baseBranch + } + + const resolveConflict = (): void => { + writeFileSync(path.join(tmpDir, 'file.txt'), 'resolved\n') + execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) + } + + const readFileText = async (): Promise => + fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8').then(normalizeGitFileText) + + it('continues a conflicted rebase without waiting on an editor', async () => { + const baseBranch = seedDivergedBranches() + execFileSync('git', ['checkout', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + expect(() => + execFileSync('git', ['rebase', baseBranch], { cwd: tmpDir, stdio: 'pipe' }) + ).toThrow() + resolveConflict() + + await dispatcher.callRequest('git.continueRebase', { worktreePath: tmpDir }) + + await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).rejects.toThrow() + await expect(fs.access(path.join(tmpDir, '.git', 'rebase-apply'))).rejects.toThrow() + await expect(readFileText()).resolves.toBe('resolved\n') + }) + + it('continues a conflicted merge without waiting on an editor', async () => { + seedDivergedBranches() + expect(() => + execFileSync('git', ['merge', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + ).toThrow() + resolveConflict() + + await dispatcher.callRequest('git.continueMerge', { worktreePath: tmpDir }) + + await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).rejects.toThrow() + await expect(readFileText()).resolves.toBe('resolved\n') + }) + + it('continues a conflicted cherry-pick without waiting on an editor', async () => { + seedDivergedBranches() + expect(() => + execFileSync('git', ['cherry-pick', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + ).toThrow() + resolveConflict() + + await dispatcher.callRequest('git.continueCherryPick', { worktreePath: tmpDir }) + + await expect(fs.access(path.join(tmpDir, '.git', 'CHERRY_PICK_HEAD'))).rejects.toThrow() + await expect(readFileText()).resolves.toBe('resolved\n') + }) + }) + describe('checkout / localBranches', () => { it('switches to an existing local branch and lists branches current-first', async () => { gitInit(tmpDir) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 58f47da9fce..84b58cf794e 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -15,6 +15,7 @@ import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from './relay-command-env' +import { editorSuppressedGitEnv } from '../shared/git-sequencer-editor-env' import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' import type { GitHandlerCommandOptions, @@ -102,6 +103,21 @@ export class GitHandler { (params, context) => this.responseAck(params, context), (params, context) => this.cancelResponseStream(params, context) ) + this.dispatcher.onRequest('git.continueMerge', (p) => + this.sequencerAction(p, ['merge', '--continue']) + ) + this.dispatcher.onRequest('git.continueRebase', (p) => + this.sequencerAction(p, ['rebase', '--continue']) + ) + this.dispatcher.onRequest('git.continueCherryPick', (p) => + this.sequencerAction(p, ['cherry-pick', '--continue']) + ) + this.dispatcher.onRequest('git.skipRebase', (p) => + this.sequencerAction(p, ['rebase', '--skip']) + ) + this.dispatcher.onRequest('git.skipCherryPick', (p) => + this.sequencerAction(p, ['cherry-pick', '--skip']) + ) // Why: a detached client's git.responseAck frames never arrive; wake any pump parked on the ack window so it re-checks staleness and exits. this.dispatcher.onClientDetached?.(() => this.responseStreams.wakeAll()) } @@ -166,7 +182,9 @@ export class GitHandler { ): Promise { const expandedCwd = expandTilde(cwd) const run = async (): Promise<{ stdout: string; stderr: string }> => { - const env = opts?.nonInteractive ? buildRelayUnattendedGitEnv() : buildRelayGitEnv() + const baseEnv = opts?.nonInteractive ? buildRelayUnattendedGitEnv() : buildRelayGitEnv() + // Why: an ambient editor can leave a headless --continue blocked forever. + const env = opts?.suppressEditor ? editorSuppressedGitEnv(baseEnv) : baseEnv if (opts?.disableOptionalLocks) { env.GIT_OPTIONAL_LOCKS = '0' } @@ -203,6 +221,17 @@ export class GitHandler { return stdout } + // Why: sequencer continuations must not open an interactive commit-message editor. + private async sequencerAction(params: Record, args: string[]) { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + try { + await this.git(args, worktreePath, { suppressEditor: true }) + } finally { + this.clearGitMutationReadCaches() + } + } + private async spawnClone( args: string[], cwd: string, diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index 712a2c277d0..f4251b84058 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -714,7 +714,7 @@ describe('ConflictSummaryCard', () => { expect(cherryPickMarkup).not.toContain('Abort rebase') }) - it('renders abort actions with the quiet outline review-conflicts button treatment', () => { + it('gives Review conflicts and abort the same outline treatment', () => { const mergeMarkup = renderToStaticMarkup( { expect(cherryPickMarkup).not.toContain('Abort rebase') }) - it('renders abort actions with the quiet outline button treatment', () => { + it('renders abort as an outlined alternative, never the loudest action', () => { const mergeMarkup = renderToStaticMarkup( ) @@ -807,4 +807,61 @@ describe('OperationBanner', () => { expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"') expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"') }) + + it('offers Resolve with AI so abort is not the only exit from a stopped operation', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Resolve with AI') + expect(buttonContaining(markup, 'Resolve with AI')).toContain('data-variant="default"') + expect(buttonContaining(markup, 'Abort rebase')).toContain('data-variant="outline"') + }) + + it('offers Resolve with AI for cherry-pick, which has no abort action', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Resolve with AI') + expect(markup).not.toContain('Abort') + }) + + it('hides Resolve with AI when source control AI actions are disabled', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('Resolve with AI') + expect(markup).toContain('Abort rebase') + }) + + it('disables both actions while an abort is in flight', () => { + const markup = renderToStaticMarkup( + + ) + + expect(buttonContaining(markup, 'Resolve with AI')).toContain('disabled') + expect(buttonContaining(markup, 'Abort rebase')).toContain('disabled') + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts b/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts index f0ac2bd8795..3e3a7093e60 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts @@ -136,3 +136,23 @@ describe('source-control branch context stats', () => { ).toEqual([]) }) }) + +describe('ahead/behind while an operation is in progress', () => { + it('shows the counters normally when nothing is running', () => { + const stats = buildSourceControlCompareBaseStats(readySummary, 'origin/main', false) + + expect(stats.map((stat) => stat.label)).toEqual(['↑3']) + }) + + // Mid-rebase HEAD is a transient replay commit, so every count is against a tree + // the user never asked about. + it('suppresses every counter while an operation is in progress', () => { + expect(buildSourceControlCompareBaseStats(readySummary, 'origin/main', true)).toEqual([]) + }) + + it('defaults to showing counters when the caller does not pass the flag', () => { + expect(buildSourceControlCompareBaseStats(readySummary, 'origin/main').length).toBeGreaterThan( + 0 + ) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx index 36aac5d4ab5..474c7972465 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import { describe, expect, it, vi } from 'vitest' import { SourceControlHeaderToolbar } from './source-control/panel/header-toolbar' import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types' -import type { GitBranchLineTotal } from '../../../../shared/git-status-types' +import type { GitBranchLineTotal, GitConflictOperation } from '../../../../shared/git-status-types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import type { PrimaryAction } from './source-control-primary-action' @@ -44,6 +44,7 @@ function renderToolbar(options?: { visibleCreatePrHeaderAction?: PrimaryAction | null hostedReview?: HostedReviewInfo | null suppressedGitHubPRNumber?: number | null + conflictOperation?: GitConflictOperation }): string { return renderToStaticMarkup( ) } @@ -178,3 +180,45 @@ describe('SourceControlHeaderToolbar branch identity', () => { expect(renderToolbar()).not.toContain('data-testid="source-control-branch-line-total"') }) }) + +describe('SourceControlHeaderToolbar identity during a rebase', () => { + const rebasingHead = { + kind: 'operation', + operation: 'rebase', + branchName: 'triage-e2e', + shortHead: '285883d', + head: '285883d1c0ffee00' + } as const + + it('names the rebasing branch instead of "Detached HEAD · "', () => { + const markup = renderToolbar({ headDisplay: rebasingHead, conflictOperation: 'rebase' }) + + expect(markup).toContain('triage-e2e') + expect(markup).toContain('rebasing') + expect(markup).not.toContain('Detached HEAD') + expect(markup).toContain('data-operation="rebase"') + expect(markup).toContain('aria-label="Current branch: triage-e2e (rebasing)"') + }) + + it('keeps the raw SHA reachable in the tooltip', () => { + const markup = renderToolbar({ headDisplay: rebasingHead, conflictOperation: 'rebase' }) + + expect(markup).toContain('285883d1c0ffee00') + }) + + it('suppresses the ahead/behind counter while the rebase runs', () => { + const running = renderToolbar({ + headDisplay: rebasingHead, + conflictOperation: 'rebase' + }) + const settled = renderToolbar({ + headDisplay: { kind: 'branch', branchName: 'triage-e2e' }, + conflictOperation: 'unknown' + }) + + expect(settled).toContain('↑1') + expect(running).not.toContain('↑1') + // The base ref itself still shows — only counts measured against a transient commit go away. + expect(running).toContain('origin/main') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/conflict-handoff-gate.test.ts b/src/renderer/src/components/right-sidebar/source-control/ai/conflict-handoff-gate.test.ts new file mode 100644 index 00000000000..91cb63817e3 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/ai/conflict-handoff-gate.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { canSendConflictsToAgent } from './conflict-handoff-gate' + +describe('canSendConflictsToAgent', () => { + it('allows the hand-off while live conflicts exist', () => { + expect(canSendConflictsToAgent(3, 'rebase')).toBe(true) + expect(canSendConflictsToAgent(1, 'unknown')).toBe(true) + }) + + it('allows the hand-off for a stopped operation with nothing unmerged', () => { + expect(canSendConflictsToAgent(0, 'rebase')).toBe(true) + expect(canSendConflictsToAgent(0, 'merge')).toBe(true) + expect(canSendConflictsToAgent(0, 'cherry-pick')).toBe(true) + }) + + it('blocks the hand-off on a clean tree with no operation', () => { + expect(canSendConflictsToAgent(0, 'unknown')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/conflict-handoff-gate.ts b/src/renderer/src/components/right-sidebar/source-control/ai/conflict-handoff-gate.ts new file mode 100644 index 00000000000..bcb53739056 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/ai/conflict-handoff-gate.ts @@ -0,0 +1,13 @@ +import type { GitConflictOperation } from '../../../../../../shared/git-status-types' + +/** + * True when the conflict prompt is worth handing to an agent. Live unmerged files always qualify; + * so does a stopped rebase/merge/cherry-pick with none, because deciding continue vs skip is the + * judgment the prompt asks for. Only a clean tree with no operation has nothing to send. + */ +export function canSendConflictsToAgent( + unresolvedConflictCount: number, + conflictOperation: GitConflictOperation +): boolean { + return unresolvedConflictCount > 0 || conflictOperation !== 'unknown' +} diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts index 65544be0a91..7594835ba70 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts @@ -17,6 +17,7 @@ import type { SourceControlTextActionId } from '../../../../../../shared/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../../../shared/source-control-ai-recipe-save' +import { canSendConflictsToAgent } from './conflict-handoff-gate' import { buildResolveConflictsPrompt } from './prompts' import { saveSourceControlAiActionRecipeForTarget, @@ -160,7 +161,7 @@ export function useSourceControlAi({ if (!activeWorktreeId) { return } - if (unresolvedConflicts.length === 0) { + if (!canSendConflictsToAgent(unresolvedConflicts.length, conflictOperation)) { toast.message( translate( 'auto.components.right.sidebar.use.source.control.ai.cfafa92509', @@ -170,7 +171,7 @@ export function useSourceControlAi({ return } setResolveConflictsComposerOpen(true) - }, [activeWorktreeId, unresolvedConflicts.length]) + }, [activeWorktreeId, conflictOperation, unresolvedConflicts.length]) const { isLaunchingCommitFailureAgent, diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts index 4cbb113ed19..6e233413fff 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts @@ -1,6 +1,7 @@ import type { SourceControlPanelFoundation } from '../panel/use-panel-foundation' import { useSourceControlCreatePrIntentCommitMessage } from '../review/use-create-pr-intent-commit-message' import { useSourceControlConflictAbort } from '../sync/use-conflict-abort' +import { useSourceControlConflictAdvance } from '../sync/use-conflict-advance' import { useSourceControlRemoteActionRunner } from '../sync/use-remote-action-runner' import { useSourceControlCommitAction } from './use-commit-action' import { useSourceControlCommitMessageGeneration } from './use-commit-message-generation' @@ -32,6 +33,7 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda getCreatePrIntentOperationTarget, grouped, isAbortingOperation, + isAdvancingOperation, openCommitGenerationDialog, pullBranch, pushBranch, @@ -44,6 +46,7 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda remoteStatusForActions, resolvedCommitMessageAi, setAbortOperationInFlightByWorktree, + setAdvanceOperationInFlightByWorktree, setCommitErrorForWorktree, setCommitInFlightByWorktree, setCommitMessageGenerationRecord, @@ -141,13 +144,27 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda setRemoteActionErrors, worktreePath }) + const conflictAdvance = useSourceControlConflictAdvance({ + activeRepoSettings, + activeWorktreeId, + conflictOperation, + isAbortingOperation, + isAdvancingOperation, + refreshActiveGitStatusAfterMutation, + refreshBranchCompareRef, + refreshGitHistoryRef, + setAdvanceOperationInFlightByWorktree, + setRemoteActionErrors, + worktreePath + }) return { ...commitAction, ...commitMessageGeneration, ...createPrIntentCommitMessage, ...remoteActionRunner, - ...conflictAbort + ...conflictAbort, + ...conflictAdvance } } diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/conflict-status-cards.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/conflict-status-cards.tsx index 04587f51be4..3ae05496644 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/conflict-status-cards.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/conflict-status-cards.tsx @@ -1,174 +1,199 @@ import React from 'react' -import { GitMerge, GitPullRequestArrow, RefreshCw, Sparkles, TriangleAlert } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { GitMerge, GitPullRequestArrow, TriangleAlert } from 'lucide-react' import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { GitConflictOperation } from '../../../../../../shared/git-status-types' +import type { + GitConflictOperation, + GitOperationProgress +} from '../../../../../../shared/git-status-types' +import { SourceControlOperationBannerActions } from './operation-banner-actions' -export function ConflictSummaryCard({ +/** + * Shared shell for both conflict states. Why: `git rebase --continue` can land + * straight in a new conflict, swapping ConflictSummaryCard for OperationBanner + * mid-flight — a shared shell keeps the box identical so only its contents change. + */ +export function OperationCardShell({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + +function conflictsHeading(conflictOperation: GitConflictOperation): string { + if (conflictOperation === 'merge') { + return translate( + 'auto.components.right.sidebar.source.control.conflict.status.cards.5302a1ddba', + 'Merge conflicts' + ) + } + if (conflictOperation === 'rebase') { + return translate( + 'auto.components.right.sidebar.source.control.conflict.status.cards.7f3af87549', + 'Rebase conflicts' + ) + } + if (conflictOperation === 'cherry-pick') { + return translate( + 'auto.components.right.sidebar.source.control.conflict.status.cards.6a8e9ad490', + 'Cherry-pick conflicts' + ) + } + return translate( + 'auto.components.right.sidebar.source.control.conflict.status.cards.bdf8772106', + 'Conflicts' + ) +} + +function inProgressHeading(conflictOperation: GitConflictOperation): string { + if (conflictOperation === 'merge') { + return translate( + 'auto.components.right.sidebar.source.control.conflict.status.cards.edc2d82a2b', + 'Merge in progress' + ) + } + if (conflictOperation === 'rebase') { + return translate( + 'auto.components.right.sidebar.source.control.conflict.status.cards.5c3707aa44', + 'Rebase in progress' + ) + } + if (conflictOperation === 'cherry-pick') { + return translate( + 'auto.components.right.sidebar.source.control.conflict.status.cards.ffe53a1da6', + 'Cherry-pick in progress' + ) + } + return translate( + 'auto.components.right.sidebar.source.control.conflict.status.cards.35eb76d323', + 'Operation in progress' + ) +} + +type SharedOperationCardProps = { + conflictOperation: GitConflictOperation + sourceControlAiActionsVisible: boolean + isResolvingWithAI: boolean + isAbortingOperation?: boolean + isAdvancingOperation?: boolean + onAbortOperation?: (operation: GitConflictOperation) => void + onContinueOperation?: (operation: GitConflictOperation) => void +} + +export function ConflictSummaryBody({ conflictOperation, unresolvedCount, sourceControlAiActionsVisible, isResolvingWithAI, isAbortingOperation = false, + isAdvancingOperation = false, onAbortOperation, + onContinueOperation, onResolveWithAI, onReview -}: { - conflictOperation: GitConflictOperation +}: SharedOperationCardProps & { unresolvedCount: number - sourceControlAiActionsVisible: boolean - isResolvingWithAI: boolean - isAbortingOperation?: boolean - onAbortOperation?: (operation: GitConflictOperation) => void onResolveWithAI: () => void onReview: () => void }): React.JSX.Element { - const operationLabel = - conflictOperation === 'merge' - ? translate( - 'auto.components.right.sidebar.source.control.conflict.status.cards.5302a1ddba', - 'Merge conflicts' - ) - : conflictOperation === 'rebase' - ? translate( - 'auto.components.right.sidebar.source.control.conflict.status.cards.7f3af87549', - 'Rebase conflicts' - ) - : conflictOperation === 'cherry-pick' - ? translate( - 'auto.components.right.sidebar.source.control.conflict.status.cards.6a8e9ad490', - 'Cherry-pick conflicts' - ) - : translate( - 'auto.components.right.sidebar.source.control.conflict.status.cards.bdf8772106', - 'Conflicts' - ) - return ( -
-
- -
-
- {translate( - 'auto.components.right.sidebar.SourceControl.d7a5942e41', - '{{value0}}: {{value1}} unresolved', - { value0: operationLabel, value1: unresolvedCount } - )} -
-
- {translate( - 'auto.components.right.sidebar.SourceControl.3eeccbb221', - 'Resolved files move back to normal changes after they leave the live conflict state.' - )} -
+ <> +
+ +
+ {translate( + 'auto.components.right.sidebar.SourceControl.d7a5942e41', + '{{value0}}: {{value1}} unresolved', + { value0: conflictsHeading(conflictOperation), value1: unresolvedCount } + )}
-
- {sourceControlAiActionsVisible ? ( - - ) : null} - - {(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? ( - - ) : null} -
-
+ + + ) +} + +/** Standalone card. The panel composes OperationCardShell + a body directly so the box survives a mid-flight swap. */ +export function ConflictSummaryCard( + props: React.ComponentProps +): React.JSX.Element { + return ( + + + ) } // Why: separate from ConflictSummaryCard because a rebase/merge/cherry-pick can be in progress with no conflicts (between steps, or resolved but pre-continue). -export function OperationBanner({ +export function OperationBannerBody({ conflictOperation, + sourceControlAiActionsVisible = false, + isResolvingWithAI = false, isAbortingOperation = false, - onAbortOperation -}: { + isAdvancingOperation = false, + operationProgress = null, + onAbortOperation, + onContinueOperation, + onResolveWithAI +}: Partial & { conflictOperation: GitConflictOperation - isAbortingOperation?: boolean - onAbortOperation?: (operation: GitConflictOperation) => void + operationProgress?: GitOperationProgress | null + onResolveWithAI?: () => void }): React.JSX.Element { - const label = - conflictOperation === 'merge' - ? translate( - 'auto.components.right.sidebar.source.control.conflict.status.cards.edc2d82a2b', - 'Merge in progress' - ) - : conflictOperation === 'rebase' - ? translate( - 'auto.components.right.sidebar.source.control.conflict.status.cards.5c3707aa44', - 'Rebase in progress' - ) - : conflictOperation === 'cherry-pick' - ? translate( - 'auto.components.right.sidebar.source.control.conflict.status.cards.ffe53a1da6', - 'Cherry-pick in progress' - ) - : translate( - 'auto.components.right.sidebar.source.control.conflict.status.cards.35eb76d323', - 'Operation in progress' - ) - const Icon = conflictOperation === 'rebase' ? GitPullRequestArrow : GitMerge + const onto = operationProgress?.onto?.trim() + const heading = + conflictOperation === 'rebase' && onto + ? translate( + 'auto.components.right.sidebar.source.control.conflict.status.cards.d047be3812', + 'Rebasing onto {{value0}}', + { value0: onto } + ) + : inProgressHeading(conflictOperation) return ( -
+ <>
- {label} + + {heading} +
- {(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? ( - - ) : null} -
+ + + ) +} + +/** Standalone card; see ConflictSummaryCard for why the panel does not use this directly. */ +export function OperationBanner( + props: React.ComponentProps +): React.JSX.Element { + return ( + + + ) } 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 8c6cac56097..7c1a2808460 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,7 +1,14 @@ import React from 'react' import { translate } from '@/i18n/i18n' -import type { GitConflictOperation } from '../../../../../../shared/git-status-types' -import { ConflictSummaryCard, OperationBanner } from './conflict-status-cards' +import type { + GitConflictOperation, + GitOperationProgress +} from '../../../../../../shared/git-status-types' +import { + ConflictSummaryBody, + OperationBannerBody, + OperationCardShell +} from './conflict-status-cards' import { EmptyState } from './empty-state' import { TooManyChangesBanner } from './too-many-changes-banner' @@ -15,7 +22,10 @@ export function SourceControlContentStatus({ conflictOperation, sourceControlAiActionsVisible, isAbortingOperation, + isAdvancingOperation, + operationProgress, onAbortOperation, + onContinueOperation, onResolveWithAi, onReviewConflicts, repositoryHuge, @@ -33,7 +43,10 @@ export function SourceControlContentStatus({ conflictOperation: GitConflictOperation sourceControlAiActionsVisible: boolean isAbortingOperation: boolean + isAdvancingOperation: boolean + operationProgress: GitOperationProgress | null onAbortOperation: (operation: GitConflictOperation) => void + onContinueOperation: (operation: GitConflictOperation) => void onResolveWithAi: () => void onReviewConflicts: () => void repositoryHuge: { limit: number } | null | undefined @@ -49,28 +62,39 @@ export function SourceControlContentStatus({ }): React.JSX.Element { return ( <> - {unresolvedConflictCount > 0 && ( + {/* Why: `git rebase --continue` can advance straight into a new conflict, so this + swaps ConflictSummaryCard for OperationBanner mid-flight. One wrapper for both + keeps the container mounted in place, so only the card contents change. */} + {(unresolvedConflictCount > 0 || conflictOperation !== 'unknown') && (
- -
- )} - {/* Why: show the operation banner when a rebase/merge/cherry-pick is in progress with no unresolved conflicts. */} - {unresolvedConflictCount === 0 && conflictOperation !== 'unknown' && ( -
- + + {unresolvedConflictCount > 0 ? ( + + ) : ( + + )} +
)} {repositoryHuge && ( diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/operation-banner-actions.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/operation-banner-actions.tsx new file mode 100644 index 00000000000..5393ac38604 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/listing/operation-banner-actions.tsx @@ -0,0 +1,142 @@ +import React from 'react' +import { GitMerge, Play, RefreshCw, Sparkles } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { GitConflictOperation } from '../../../../../../shared/git-status-types' + +export type SourceControlOperationActionProps = { + conflictOperation: GitConflictOperation + hasUnresolvedConflicts?: boolean + sourceControlAiActionsVisible?: boolean + isResolvingWithAI?: boolean + isAbortingOperation?: boolean + isAdvancingOperation?: boolean + onAbortOperation?: (operation: GitConflictOperation) => void + onContinueOperation?: (operation: GitConflictOperation) => void + onResolveWithAI?: () => void + onReviewConflicts?: () => void +} + +function continueLabel(operation: GitConflictOperation): string { + if (operation === 'merge') { + return translate( + 'auto.components.right.sidebar.source.control.listing.operation.banner.actions.7a41c9e6b2', + 'Continue merge' + ) + } + if (operation === 'cherry-pick') { + return translate( + 'auto.components.right.sidebar.source.control.listing.operation.banner.actions.8b52dae713', + 'Continue cherry-pick' + ) + } + return translate( + 'auto.components.right.sidebar.source.control.listing.operation.banner.actions.9c63ebf824', + 'Continue rebase' + ) +} + +function abortLabel(operation: GitConflictOperation): string { + return operation === 'rebase' + ? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase') + : translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge') +} + +type OperationAction = { + key: string + label: string + icon: React.JSX.Element | null + onClick: () => void + // Escape hatches never take the primary slot, so they never read as a way forward. + quiet?: boolean +} + +const SPINNER = + +/** + * One button per row: a single `default` primary over outlined alternatives. Order below + * is the priority order — whichever action is available first takes the primary slot. + */ +export function SourceControlOperationBannerActions({ + conflictOperation, + hasUnresolvedConflicts = false, + sourceControlAiActionsVisible = false, + isResolvingWithAI = false, + isAbortingOperation = false, + isAdvancingOperation = false, + onAbortOperation, + onContinueOperation, + onResolveWithAI, + onReviewConflicts +}: SourceControlOperationActionProps): React.JSX.Element | null { + // Why: git refuses `--continue` while any file is still unmerged, so offering it + // during conflicts is offering a button that can only fail. + const continueAvailable = Boolean(onContinueOperation) && !hasUnresolvedConflicts + const busy = isResolvingWithAI || isAbortingOperation || isAdvancingOperation + + const actions: OperationAction[] = [] + if (continueAvailable) { + actions.push({ + key: 'continue', + label: continueLabel(conflictOperation), + icon: isAdvancingOperation ? SPINNER : , + onClick: () => onContinueOperation?.(conflictOperation) + }) + } + if (sourceControlAiActionsVisible && onResolveWithAI) { + actions.push({ + key: 'resolve-with-ai', + label: translate('auto.components.right.sidebar.SourceControl.f6cb48b6fe', 'Resolve with AI'), + icon: isResolvingWithAI ? SPINNER : , + onClick: onResolveWithAI + }) + } + if (onReviewConflicts && hasUnresolvedConflicts) { + actions.push({ + key: 'review', + label: translate( + 'auto.components.right.sidebar.SourceControl.27a50fe970', + 'Review conflicts' + ), + icon: , + onClick: onReviewConflicts + }) + } + if ((conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation) { + actions.push({ + key: 'abort', + label: abortLabel(conflictOperation), + icon: isAbortingOperation ? SPINNER : null, + onClick: () => onAbortOperation(conflictOperation), + quiet: true + }) + } + + // Why: a quiet action is never promoted — abort alone stays outlined rather than + // becoming the loudest thing in the card by default. + const primary = actions.find((action) => !action.quiet) ?? null + if (actions.length === 0) { + return null + } + + const render = (action: OperationAction, variant: 'default' | 'outline'): React.JSX.Element => ( + + ) + + return ( +
+ {actions.map((action) => render(action, action === primary ? 'default' : 'outline'))} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/operation-banner-transition.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/operation-banner-transition.test.tsx new file mode 100644 index 00000000000..343c3041813 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/listing/operation-banner-transition.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom + +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { GitOperationProgress } from '../../../../../../shared/git-status-types' +import { SourceControlContentStatus } from './content-status' + +const progress: GitOperationProgress = { + headName: 'triage-e2e', + onto: 'origin/main', + currentStep: 3, + totalSteps: 7, + commitSubject: 'ci: split the e2e shards', + stoppedBy: 'pick' +} + +function baseProps(unresolvedConflictCount: number) { + return { + unresolvedConflictCount, + conflictOperation: 'rebase' as const, + sourceControlAiActionsVisible: true, + isAbortingOperation: false, + isAdvancingOperation: false, + operationProgress: progress, + onAbortOperation: vi.fn(), + onContinueOperation: vi.fn(), + onResolveWithAi: vi.fn(), + onReviewConflicts: vi.fn(), + repositoryHuge: null, + worktreeId: 'wt-1', + onRetryStatus: vi.fn(async () => {}), + showGenericEmptyState: false, + normalizedFilter: '', + branchBaseRef: 'origin/main', + filterTooLarge: false, + hasFilteredUncommittedEntries: true, + hasFilteredBranchEntries: false, + filterQuery: '' + } +} + +describe('banner swap when continue lands in a new conflict', () => { + it('keeps the card container mounted in place while the contents change', () => { + // Resolved-and-ready: the OperationBanner is showing. + const { container, rerender } = render() + const cardBefore = container.querySelector('[data-testid="source-control-operation-card"]') + expect(cardBefore).not.toBeNull() + expect(container.textContent).toContain('Rebasing onto origin/main') + expect(container.textContent).toContain('Continue rebase') + + // `git rebase --continue` advanced into a NEW conflict on the next step. + rerender( + + ) + + const cardAfter = container.querySelector('[data-testid="source-control-operation-card"]') + // Same DOM node: the panel does not unmount and remount the box under the user. + expect(cardAfter).toBe(cardBefore) + expect(container.textContent).toContain('Rebase conflicts: 2 unresolved') + expect(container.textContent).toContain('Review conflicts') + }) + + it('keeps the container mounted when the operation finishes and the card leaves', () => { + const { container, rerender } = render() + expect(container.querySelector('[data-testid="source-control-operation-card"]')).not.toBeNull() + + rerender( + + ) + + expect(container.querySelector('[data-testid="source-control-operation-card"]')).toBeNull() + }) + + it('swaps back from conflicts to the in-progress banner on the same node', () => { + const { container, rerender } = render() + const cardBefore = container.querySelector('[data-testid="source-control-operation-card"]') + + rerender() + + expect(container.querySelector('[data-testid="source-control-operation-card"]')).toBe( + cardBefore + ) + expect(container.textContent).toContain('Continue rebase') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/operation-banner.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/operation-banner.test.tsx new file mode 100644 index 00000000000..50e92b3244c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/listing/operation-banner.test.tsx @@ -0,0 +1,168 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { GitOperationProgress } from '../../../../../../shared/git-status-types' +import { ConflictSummaryCard, OperationBanner } from './conflict-status-cards' + +function buttonContaining(markup: string, label: string): string { + const match = markup.match( + new RegExp(`]*>(?:(?!).)*${label}[\\s\\S]*?`) + ) + return match?.[0] ?? '' +} + +const handlers = { + onAbortOperation: vi.fn(), + onContinueOperation: vi.fn(), + onResolveWithAI: vi.fn() +} + +const progress: GitOperationProgress = { + headName: 'triage-e2e', + onto: 'origin/main', + currentStep: 3, + totalSteps: 7, + commitSubject: 'ci: split the e2e shards', + stoppedBy: 'pick' +} + +const render = (props: Partial> = {}) => + renderToStaticMarkup( + + ) + +describe('OperationBanner heading', () => { + it('names the ref a rebase is replaying onto', () => { + expect(render()).toContain('Rebasing onto origin/main') + }) + + // Wire compatibility: a host that predates operationProgress omits it entirely. + it('degrades to the plain in-progress banner when the host reported no progress', () => { + const markup = render({ operationProgress: null }) + + expect(markup).toContain('Rebase in progress') + expect(markup).not.toContain('Rebasing onto') + }) + + it('keeps the banner to the heading — no step meter or replayed-commit subject', () => { + const markup = render() + + expect(markup).not.toContain('3 / 7') + expect(markup).not.toContain('ci: split the e2e shards') + }) +}) + +describe('OperationBanner actions', () => { + it('leads with Continue and keeps AI and Abort quiet when no conflicts remain', () => { + const markup = render() + + expect(buttonContaining(markup, 'Continue rebase')).toContain('data-variant="default"') + expect(buttonContaining(markup, 'Resolve with AI')).toContain('data-variant="outline"') + expect(buttonContaining(markup, 'Abort rebase')).toContain('data-variant="outline"') + }) + + it('offers no way to skip the commit being replayed', () => { + expect(render()).not.toContain('Skip') + }) + + it('labels Continue for the operation actually running', () => { + expect(render({ conflictOperation: 'merge' })).toContain('Continue merge') + expect(render({ conflictOperation: 'cherry-pick' })).toContain('Continue cherry-pick') + expect(render({ conflictOperation: 'rebase' })).toContain('Continue rebase') + }) + + it('hides Continue when the caller offers no handler for it', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('Continue rebase') + expect(buttonContaining(markup, 'Resolve with AI')).toContain('data-variant="default"') + }) + + it('disables every action while an abort is in flight', () => { + const markup = render({ isAbortingOperation: true }) + + expect(buttonContaining(markup, 'Continue rebase')).toContain('disabled') + expect(buttonContaining(markup, 'Abort rebase')).toContain('disabled') + expect(buttonContaining(markup, 'Resolve with AI')).toContain('disabled') + }) + + it('disables every action while a continue is in flight', () => { + const markup = render({ isAdvancingOperation: true }) + + expect(buttonContaining(markup, 'Continue rebase')).toContain('disabled') + expect(buttonContaining(markup, 'Abort rebase')).toContain('disabled') + expect(buttonContaining(markup, 'Resolve with AI')).toContain('disabled') + }) +}) + +describe('ConflictSummaryCard', () => { + const renderSummary = ( + props: Partial> = {} + ): string => + renderToStaticMarkup( + + ) + + it('offers only Resolve with AI, Review and Abort while conflicts are unresolved', () => { + const markup = renderSummary() + + expect(buttonContaining(markup, 'Resolve with AI')).toContain('data-variant="default"') + expect(buttonContaining(markup, 'Review conflicts')).toContain('data-variant="outline"') + expect(buttonContaining(markup, 'Abort rebase')).toContain('data-variant="outline"') + // git refuses `--continue` while files are still unmerged. + expect(markup).not.toContain('Continue rebase') + expect(markup).not.toContain('Skip') + }) + + it('leads with exactly one primary — the rest are outlined alternatives', () => { + expect(renderSummary().match(/data-variant="default"/g)).toHaveLength(1) + }) + + it('promotes Review conflicts when AI resolution is unavailable', () => { + const markup = renderSummary({ sourceControlAiActionsVisible: false }) + + expect(buttonContaining(markup, 'Review conflicts')).toContain('data-variant="default"') + expect(buttonContaining(markup, 'Abort rebase')).toContain('data-variant="outline"') + }) + + it('stacks one full-width button per row: AI, then Review, then Abort', () => { + const markup = renderSummary() + + for (const label of ['Resolve with AI', 'Review conflicts', 'Abort rebase']) { + expect(buttonContaining(markup, label)).toContain('w-full') + expect(buttonContaining(markup, label)).not.toContain('flex-1') + } + expect(markup.indexOf('Resolve with AI')).toBeLessThan(markup.indexOf('Review conflicts')) + expect(markup.indexOf('Review conflicts')).toBeLessThan(markup.indexOf('Abort rebase')) + }) + + it('states the unresolved count without a meter, subject or recovery note', () => { + const markup = renderSummary() + + expect(markup).toContain('Rebase conflicts: 1 unresolved') + expect(markup).not.toContain('3 / 7') + expect(markup).not.toContain('ci: split the e2e shards') + expect(markup).not.toContain('Resolved files move back') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts index 94fc5879f85..4646492b283 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts @@ -3,7 +3,10 @@ import { getConnectionId } from '@/lib/connection-context' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' -import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' +import { + getWorktreeGitIdentityDisplay, + getWorktreeGitOperationIdentityDisplay +} from '@/lib/worktree-git-identity-display' import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' @@ -35,8 +38,11 @@ export function useSourceControlWorktreeContext() { const activeRepoPath = activeRepo?.path ?? null const activeRepoConnectionId = activeRepo?.connectionId ?? null const activeRepoExecutionHostId = activeRepo?.executionHostId ?? null - const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null - const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' + const plainGitIdentityDisplay = activeWorktree + ? getWorktreeGitIdentityDisplay(activeWorktree) + : null + const branchName = + plainGitIdentityDisplay?.kind === 'branch' ? plainGitIdentityDisplay.branchName : '' const entries = useAppStore((s) => activeWorktreeId ? (s.gitStatusByWorktree[activeWorktreeId] ?? EMPTY_GIT_STATUS_ENTRIES) @@ -69,6 +75,23 @@ export function useSourceControlWorktreeContext() { activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' ) const conflictOperationsByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree) + const operationProgress = useAppStore((s) => + activeWorktreeId ? (s.gitOperationProgressByWorktree?.[activeWorktreeId] ?? null) : null + ) + // Why: git detaches HEAD mid-rebase, so the plain identity would read "Detached + // HEAD · " and hide the branch being replayed. + const gitIdentityDisplay = useMemo( + () => + activeWorktree + ? getWorktreeGitOperationIdentityDisplay({ + branch: activeWorktree.branch, + head: activeWorktree.head, + conflictOperation, + operationHeadName: operationProgress?.headName ?? null + }) + : null, + [activeWorktree, conflictOperation, operationProgress?.headName] + ) // Why: leave undefined until fetchUpstreamStatus resolves; a synthetic "no upstream" flashes "Publish Branch" on worktree switch. const remoteStatus = useAppStore((s) => activeWorktreeId ? s.remoteStatusesByWorktree[activeWorktreeId] : undefined @@ -163,6 +186,7 @@ export function useSourceControlWorktreeContext() { branchSummary, conflictOperation, conflictOperationsByWorktree, + operationProgress, entries, gitIdentityDisplay, hasUncommittedEntries, diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx index 9a44ecaee26..a4af4f5e520 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx @@ -1,12 +1,15 @@ import React from 'react' import { ExternalLink, Loader2, RefreshCw } from 'lucide-react' import type { GitBranchCompareSummary } from '../../../../../../shared/git-diff-compare-types' -import type { GitBranchLineTotal } from '../../../../../../shared/git-status-types' +import type { + GitBranchLineTotal, + GitConflictOperation +} from '../../../../../../shared/git-status-types' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' +import { HeadIdentity, resolveHeadFlowLabel } from './head-identity-chip' import { SourceControlHeaderIconButton } from './header-icon-button' import { SourceControlBranchLineTotalChip } from './branch-line-total-chip' import { @@ -89,60 +92,6 @@ function ManualReviewLinkButton({ ) } -function resolveHeadFlowLabel( - display: WorktreeGitIdentityDisplay | null | undefined -): string | null { - if (display?.kind === 'branch') { - return display.branchName - } - if (display?.kind === 'detached') { - return display.sourceControlLabel - } - return null -} - -function HeadIdentity({ display }: { display: WorktreeGitIdentityDisplay }): React.JSX.Element { - if (display.kind === 'detached') { - return ( - - ) - } - - const branchAriaLabel = translate( - 'auto.components.right.sidebar.SourceControl.a4e93c21d7', - 'Current branch: {{value0}}', - { value0: display.branchName } - ) - - // Why: focusable + tooltip so truncated long branch names stay discoverable. - // Native title omitted — Radix Tooltip already surfaces the full name on hover. - // `block` is load-bearing: `truncate` clips nothing on an inline box, so an - // inline span here let long names run under the line-total chip. - return ( - - - - {display.branchName} - - - - {display.branchName} - - - ) -} - function CompareFlowGroup({ flowLabel, busy, @@ -282,6 +231,7 @@ export function SourceControlBranchContextRow({ headDisplay = null, manualReviewUrl, branchLineTotal, + conflictOperation, onChangeBaseRef, onRetry }: { @@ -290,6 +240,7 @@ export function SourceControlBranchContextRow({ headDisplay?: WorktreeGitIdentityDisplay | null manualReviewUrl?: string | null branchLineTotal?: GitBranchLineTotal | null + conflictOperation?: GitConflictOperation onChangeBaseRef: () => void onRetry: () => void }): React.JSX.Element | null { @@ -384,9 +335,11 @@ export function SourceControlBranchContextRow({ ) } - const compareStatNodes = buildSourceControlCompareBaseStats(summary, displayedBaseRef).map( - (stat) => - ) + const compareStatNodes = buildSourceControlCompareBaseStats( + summary, + displayedBaseRef, + conflictOperation !== undefined && conflictOperation !== 'unknown' + ).map((stat) => ) return ( diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-stats.ts b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-stats.ts index 0f68fb1a547..8c277370f21 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-stats.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-stats.ts @@ -84,11 +84,18 @@ export function shouldShowSourceControlBranchContextChrome( // the case this row exists for — a rebased branch that has also fallen behind its base. export function buildSourceControlCompareBaseStats( summary: GitBranchCompareSummary | null | undefined, - baseRef: string + baseRef: string, + operationInProgress = false ): SourceControlBranchContextStat[] { if (summary?.status !== 'ready') { return [] } + // Why: mid-rebase HEAD is a transient replay commit, so every count here is + // measured against a tree the user never asked about. No number beats a wrong one. + if (operationInProgress) { + return [] + } + const baseLabel = formatSourceControlRefLabel(baseRef) const stats: SourceControlBranchContextStat[] = [] const commitsAhead = summary.commitsAhead diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/head-identity-chip.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/head-identity-chip.tsx new file mode 100644 index 00000000000..a8b8055b111 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/panel/head-identity-chip.tsx @@ -0,0 +1,131 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' +import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' + +type OperationIdentity = Extract + +/** Short qualifier appended to the branch name while an operation replays commits. */ +export function operationIdentityQualifier(operation: OperationIdentity['operation']): string { + if (operation === 'rebase') { + return translate( + 'auto.components.right.sidebar.source.control.panel.head.identity.chip.1a7c4e9b30', + 'rebasing' + ) + } + if (operation === 'merge') { + return translate( + 'auto.components.right.sidebar.source.control.panel.head.identity.chip.2f5d81c744', + 'merging' + ) + } + return translate( + 'auto.components.right.sidebar.source.control.panel.head.identity.chip.3b9e027a51', + 'cherry-picking' + ) +} + +/** Flow label for the "head → base" accessible grouping. */ +export function resolveHeadFlowLabel( + display: WorktreeGitIdentityDisplay | null | undefined +): string | null { + if (display?.kind === 'branch') { + return display.branchName + } + if (display?.kind === 'operation') { + return `${display.branchName} · ${operationIdentityQualifier(display.operation)}` + } + if (display?.kind === 'detached') { + return display.sourceControlLabel + } + return null +} + +function OperationIdentityChip({ display }: { display: OperationIdentity }): React.JSX.Element { + const qualifier = operationIdentityQualifier(display.operation) + const ariaLabel = translate( + 'auto.components.right.sidebar.source.control.panel.head.identity.chip.4c1a63f8d2', + 'Current branch: {{value0}} ({{value1}})', + { value0: display.branchName, value1: qualifier } + ) + // Why: the raw SHA is still the ground truth for a detached mid-operation HEAD; + // it moves to the tooltip so the chip can name the branch instead. + const tooltip = display.shortHead + ? translate( + 'auto.components.right.sidebar.source.control.panel.head.identity.chip.5d2b74a9e3', + '{{value0}} — HEAD is detached at {{value1}} until the operation finishes.', + { value0: display.branchName, value1: display.head || display.shortHead } + ) + : display.branchName + + return ( + + + + {display.branchName} + {` · ${qualifier}`} + + + + {tooltip} + + + ) +} + +export function HeadIdentity({ + display +}: { + display: WorktreeGitIdentityDisplay +}): React.JSX.Element { + if (display.kind === 'detached') { + return ( + + ) + } + + if (display.kind === 'operation') { + return + } + + const branchAriaLabel = translate( + 'auto.components.right.sidebar.SourceControl.a4e93c21d7', + 'Current branch: {{value0}}', + { value0: display.branchName } + ) + + // Why: focusable + tooltip so truncated long branch names stay discoverable. + // Native title omitted — Radix Tooltip already surfaces the full name on hover. + // `block` is load-bearing: `truncate` clips nothing on an inline box, so an + // inline span here let long names run under the line-total chip. + return ( + + + + {display.branchName} + + + + {display.branchName} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx index 81f89469bf3..7b7358bcd20 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx @@ -1,7 +1,10 @@ import React, { useCallback, useEffect, useRef } from 'react' import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react' import type { GitBranchCompareSummary } from '../../../../../../shared/git-diff-compare-types' -import type { GitBranchLineTotal } from '../../../../../../shared/git-status-types' +import type { + GitBranchLineTotal, + GitConflictOperation +} from '../../../../../../shared/git-status-types' import type { SourceControlViewMode } from '../../../../../../shared/ui-chrome-types' import type { HostedReviewInfo } from '../../../../../../shared/hosted-review' import type { PrimaryAction } from '../../source-control-primary-action' @@ -41,6 +44,7 @@ type SourceControlHeaderToolbarProps = { headDisplay?: WorktreeGitIdentityDisplay | null manualReviewUrl?: string | null branchLineTotal?: GitBranchLineTotal | null + conflictOperation?: GitConflictOperation } function HostedReviewToolbarLink({ @@ -168,7 +172,8 @@ export function SourceControlHeaderToolbar({ compareBaseRef, headDisplay = null, manualReviewUrl, - branchLineTotal + branchLineTotal, + conflictOperation }: SourceControlHeaderToolbarProps): React.JSX.Element { const filterInputRef = useRef(null) const normalizedFilter = filterQuery.trim() @@ -320,6 +325,7 @@ export function SourceControlHeaderToolbar({ headDisplay={headDisplay} manualReviewUrl={manualReviewUrl} branchLineTotal={branchLineTotal} + conflictOperation={conflictOperation} onChangeBaseRef={onChangeBaseRef} onRetry={onRefreshBranchCompare} /> 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 4a0099adec9..a1d0145ce22 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 @@ -31,6 +31,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { gitHistoryState, handleAbortOperationForConflict, handleCommitAction, + handleContinueOperation, handleContextMenu, handleOpenDiff, handleResolveConflictsWithAI, @@ -41,10 +42,12 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { handleUnstagePaths, hasUncommittedEntries, isAbortingOperation, + isAdvancingOperation, isExecutingBulk, isGitHistoryVisible, loadCommitFiles, normalizedFilter, + operationProgress, openAllDiffs, openBranchAllDiffs, openCommitFile, @@ -89,7 +92,10 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { conflictOperation={conflictOperation} sourceControlAiActionsVisible={sourceControlAiActionsVisible} isAbortingOperation={isAbortingOperation} + isAdvancingOperation={isAdvancingOperation} + operationProgress={operationProgress} onAbortOperation={handleAbortOperationForConflict} + onContinueOperation={handleContinueOperation} onResolveWithAi={() => void handleResolveConflictsWithAI()} onReviewConflicts={() => { openConflictReview( diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx index ff59de95191..b31e3afa3ed 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx @@ -19,6 +19,7 @@ export function SourceControlPanelReady(props: SourceControlPanelReadyProps) { compareBaseRef, deleteDiffComment, diffCommentCount, + conflictOperation, diffCommentsCopied, diffCommentsExpanded, diffCommentsForActive, @@ -89,6 +90,7 @@ export function SourceControlPanelReady(props: SourceControlPanelReadyProps) { branchLineTotal={branchLineTotal} compareBaseRef={compareBaseRef} headDisplay={gitIdentityDisplay} + conflictOperation={conflictOperation} manualReviewUrl={manualReviewUrl} /> diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts b/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts index b816ef74cc8..4a137090a6b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts @@ -48,7 +48,11 @@ export function useSourceControlWorktreeOperationState({ const [abortOperationInFlightByWorktree, setAbortOperationInFlightByWorktree] = useState< Record >({}) + const [advanceOperationInFlightByWorktree, setAdvanceOperationInFlightByWorktree] = useState< + Record + >({}) const isAbortingOperation = abortOperationInFlightByWorktree[activeWorktreeId ?? ''] ?? false + const isAdvancingOperation = advanceOperationInFlightByWorktree[activeWorktreeId ?? ''] ?? false const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false // Why: per-worktree shape (like commit) so navigating worktrees mid-generation never cancels the in-flight request. const generateInFlightRef = useRef>({}) @@ -143,6 +147,7 @@ export function useSourceControlWorktreeOperationState({ setRemoteActionErrors((prev) => pruneRecord(prev)) setCommitInFlightByWorktree((prev) => pruneRecord(prev)) setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev)) + setAdvanceOperationInFlightByWorktree((prev) => pruneRecord(prev)) setGenerateInFlightByWorktree((prev) => pruneRecord(prev)) setGenerateErrors((prev) => pruneRecord(prev)) setCreatePrIntentInFlightByWorktree((prev) => pruneRecord(prev)) @@ -190,6 +195,7 @@ export function useSourceControlWorktreeOperationState({ return { abortOperationInFlightByWorktree, + advanceOperationInFlightByWorktree, activeRemoteActionSequence, commitDraftsRef, commitError, @@ -207,12 +213,14 @@ export function useSourceControlWorktreeOperationState({ generateInFlightByWorktree, generateInFlightRef, isAbortingOperation, + isAdvancingOperation, isCommitting, isCreatePrIntentInFlight, isCreatingPr, remoteActionError, remoteActionErrorSequenceByWorktreeRef, setAbortOperationInFlightByWorktree, + setAdvanceOperationInFlightByWorktree, setCommitErrorForWorktree, setCommitInFlightByWorktree, setCreatePrInFlightByWorktree, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/action-error.ts b/src/renderer/src/components/right-sidebar/source-control/sync/action-error.ts index 2aad59eab00..b352e16b27c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/action-error.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/action-error.ts @@ -2,7 +2,11 @@ import type { RemoteOpKind } from '../../source-control-primary-action' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' export type AbortActionErrorKind = 'abort_merge' | 'abort_rebase' -export type SourceControlActionErrorKind = RemoteOpKind | AbortActionErrorKind +export type AdvanceActionErrorKind = 'continue_operation' +export type SourceControlActionErrorKind = + | RemoteOpKind + | AbortActionErrorKind + | AdvanceActionErrorKind export type SourceControlRecoveryStatusEntry = Pick export const SOURCE_CONTROL_ACTION_ERROR_ENTRY_SNAPSHOT_LIMIT = 120 diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.test.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.test.tsx new file mode 100644 index 00000000000..7614f7828d2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSourceControlConflictAdvance } from './use-conflict-advance' + +const { runners, toastErrorMock } = vi.hoisted(() => ({ + toastErrorMock: vi.fn(), + runners: { + continueMerge: vi.fn(), + continueRebase: vi.fn(), + continueCherryPick: vi.fn() + } +})) + +vi.mock('sonner', () => ({ toast: { error: toastErrorMock } })) +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/runtime/runtime-git-client', () => ({ + continueRuntimeGitMerge: (...a: unknown[]) => runners.continueMerge(...a), + continueRuntimeGitRebase: (...a: unknown[]) => runners.continueRebase(...a), + continueRuntimeGitCherryPick: (...a: unknown[]) => runners.continueCherryPick(...a) +})) +vi.mock('./remote-refresh', () => ({ refreshSourceControlAfterRemoteAction: vi.fn() })) + +const setAdvanceOperationInFlightByWorktree = vi.fn() +const setRemoteActionErrors = vi.fn() + +type AdvanceOptions = Parameters[0] + +function setup(overrides: Partial = {}) { + return renderHook(() => + useSourceControlConflictAdvance({ + activeRepoSettings: null, + activeWorktreeId: 'wt-1', + conflictOperation: 'rebase', + isAdvancingOperation: false, + isAbortingOperation: false, + refreshActiveGitStatusAfterMutation: vi.fn(), + refreshBranchCompareRef: { current: vi.fn() }, + refreshGitHistoryRef: { current: vi.fn() }, + setAdvanceOperationInFlightByWorktree, + setRemoteActionErrors, + worktreePath: '/repo', + ...overrides + }) + ) +} + +describe('useSourceControlConflictAdvance', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('continues the running operation', async () => { + const { result } = setup() + + await act(async () => { + result.current.handleContinueOperation('rebase') + }) + + expect(runners.continueRebase).toHaveBeenCalledTimes(1) + }) + + it('routes each operation to its own runner', async () => { + const merge = setup({ conflictOperation: 'merge' }) + await act(async () => { + merge.result.current.handleContinueOperation('merge') + }) + const cherry = setup({ conflictOperation: 'cherry-pick' }) + await act(async () => { + cherry.result.current.handleContinueOperation('cherry-pick') + }) + + expect(runners.continueMerge).toHaveBeenCalledTimes(1) + expect(runners.continueCherryPick).toHaveBeenCalledTimes(1) + expect(runners.continueRebase).not.toHaveBeenCalled() + }) + + it('ignores a request for an operation that is no longer the one running', async () => { + const { result } = setup({ conflictOperation: 'merge' }) + + await act(async () => { + result.current.handleContinueOperation('rebase') + }) + + expect(runners.continueRebase).not.toHaveBeenCalled() + }) + + it('refuses to advance while an abort is already in flight', async () => { + const { result } = setup({ isAbortingOperation: true }) + + await act(async () => { + result.current.handleContinueOperation('rebase') + }) + + expect(runners.continueRebase).not.toHaveBeenCalled() + }) + + it('surfaces a failure and clears the in-flight flag', async () => { + runners.continueRebase.mockRejectedValueOnce(new Error('needs merge')) + const { result } = setup() + + await act(async () => { + result.current.handleContinueOperation('rebase') + }) + + expect(toastErrorMock).toHaveBeenCalledTimes(1) + // Set true on entry, false in the finally block — a stuck flag would disable the banner forever. + expect(setAdvanceOperationInFlightByWorktree).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.ts new file mode 100644 index 00000000000..f5fc8024f8d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-advance.ts @@ -0,0 +1,128 @@ +import { useCallback } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { getConnectionId } from '@/lib/connection-context' +import { + continueRuntimeGitCherryPick, + continueRuntimeGitMerge, + continueRuntimeGitRebase +} from '@/runtime/runtime-git-client' +import type { GitConflictOperation } from '../../../../../../shared/git-status-types' +import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' +import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' +import { refreshSourceControlAfterRemoteAction } from './remote-refresh' +import type { SourceControlStatusRefresh } from './use-status-refresh' + +const CONTINUE_RUNNERS = { + merge: continueRuntimeGitMerge, + rebase: continueRuntimeGitRebase, + 'cherry-pick': continueRuntimeGitCherryPick +} as const + +/** Continue for an in-progress merge/rebase/cherry-pick: moves the sequencer forward. */ +export function useSourceControlConflictAdvance({ + activeRepoSettings, + activeWorktreeId, + conflictOperation, + isAdvancingOperation, + isAbortingOperation, + refreshActiveGitStatusAfterMutation, + refreshBranchCompareRef, + refreshGitHistoryRef, + setAdvanceOperationInFlightByWorktree, + setRemoteActionErrors, + worktreePath +}: { + activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeWorktreeId: string | null + conflictOperation: GitConflictOperation + isAdvancingOperation: boolean + isAbortingOperation: boolean + refreshActiveGitStatusAfterMutation: SourceControlStatusRefresh['refreshActiveGitStatusAfterMutation'] + refreshBranchCompareRef: React.RefObject<() => Promise> + refreshGitHistoryRef: React.RefObject<() => Promise> + setAdvanceOperationInFlightByWorktree: SourceControlWorktreeOperationState['setAdvanceOperationInFlightByWorktree'] + setRemoteActionErrors: SourceControlWorktreeOperationState['setRemoteActionErrors'] + worktreePath: string | null +}) { + const runAdvance = useCallback( + async (requestedOperation: GitConflictOperation): Promise => { + if ( + !activeWorktreeId || + !worktreePath || + conflictOperation !== requestedOperation || + isAdvancingOperation || + isAbortingOperation + ) { + return + } + const runner = CONTINUE_RUNNERS[requestedOperation as keyof typeof CONTINUE_RUNNERS] + if (!runner) { + return + } + + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + setAdvanceOperationInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) + setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + try { + await runner({ + // Why: route by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + toast.error( + translate( + 'auto.components.right.sidebar.source.control.sync.use.conflict.advance.b84fcd7ea6', + 'Continue {{value0}} failed', + { value0: requestedOperation } + ), + { description: message } + ) + setRemoteActionErrors((prev) => ({ + ...prev, + [activeWorktreeId]: { + kind: 'continue_operation', + message, + rawError: message + } + })) + } finally { + setAdvanceOperationInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) + // Why: continue can land straight in a NEW conflict, so the banner must re-read status. + refreshSourceControlAfterRemoteAction({ + refreshGitStatus: refreshActiveGitStatusAfterMutation, + refreshBranchCompare: refreshBranchCompareRef.current, + refreshGitHistory: refreshGitHistoryRef.current + }) + } + }, + [ + activeRepoSettings, + activeWorktreeId, + conflictOperation, + isAbortingOperation, + isAdvancingOperation, + refreshActiveGitStatusAfterMutation, + refreshBranchCompareRef, + refreshGitHistoryRef, + setAdvanceOperationInFlightByWorktree, + setRemoteActionErrors, + worktreePath + ] + ) + + const handleContinueOperation = useCallback( + (operation: GitConflictOperation): void => { + void runAdvance(operation) + }, + [runAdvance] + ) + + return { handleContinueOperation } +} + +export type SourceControlConflictAdvance = ReturnType diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 81ff0bddb87..4e3b9daff63 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11841,7 +11841,6 @@ "413a3ba113": "conflict", "27a50fe970": "Review conflicts", "f6cb48b6fe": "Resolve with AI", - "3eeccbb221": "Resolved files move back to normal changes after they leave the live conflict state.", "c321542ee2": "Delete note on line {{value0}}", "b656381c18": "Delete note", "c085946bda": "Copy note on line {{value0}}", @@ -12606,7 +12605,8 @@ "edc2d82a2b": "Merge in progress", "5c3707aa44": "Rebase in progress", "ffe53a1da6": "Cherry-pick in progress", - "35eb76d323": "Operation in progress" + "35eb76d323": "Operation in progress", + "d047be3812": "Rebasing onto {{value0}}" } } }, @@ -12636,6 +12636,39 @@ "0904be2505": "Clear the message to regenerate.", "1ea9ba37aa": "Pick an agent in Settings -> Git -> Source Control AI." } + }, + "listing": { + "operation": { + "banner": { + "actions": { + "7a41c9e6b2": "Continue merge", + "8b52dae713": "Continue cherry-pick", + "9c63ebf824": "Continue rebase" + } + } + } + }, + "panel": { + "head": { + "identity": { + "chip": { + "1a7c4e9b30": "rebasing", + "2f5d81c744": "merging", + "3b9e027a51": "cherry-picking", + "4c1a63f8d2": "Current branch: {{value0}} ({{value1}})", + "5d2b74a9e3": "{{value0}} — HEAD is detached at {{value1}} until the operation finishes." + } + } + } + }, + "sync": { + "use": { + "conflict": { + "advance": { + "b84fcd7ea6": "Continue {{value0}} failed" + } + } + } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 91e9dc49dcc..f27fa7ffdf7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10567,7 +10567,6 @@ "413a3ba113": "conflicto", "27a50fe970": "Revisar conflictos", "f6cb48b6fe": "Resolver con IA", - "3eeccbb221": "Los archivos resueltos vuelven a los cambios normales después de salir del estado de conflicto activo.", "c321542ee2": "Eliminar nota en la línea {{value0}}", "b656381c18": "Eliminar nota", "c085946bda": "Copiar nota en la línea {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 493dfce6bda..c00f6aec1dc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10567,7 +10567,6 @@ "413a3ba113": "競合", "27a50fe970": "競合をレビューする", "f6cb48b6fe": "AIで解決する", - "3eeccbb221": "解決されたファイルは、ライブ競合状態を抜けた後、通常の変更に戻ります。", "c321542ee2": "{{value0}} 行目のメモを削除", "b656381c18": "メモの削除", "c085946bda": "{{value0}} 行目のメモをコピーします", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 583b6ba2da6..f6f70448a96 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10575,7 +10575,6 @@ "413a3ba113": "충돌", "27a50fe970": "충돌 리뷰", "f6cb48b6fe": "AI로 해결", - "3eeccbb221": "해결된 파일은 라이브 충돌 상태를 벗어나면 다시 일반 변경 사항으로 돌아갑니다.", "c321542ee2": "{{value0}}행의 메모 삭제", "b656381c18": "메모 삭제", "c085946bda": "{{value0}}행에 메모 복사", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dc0cf670765..70e054779dc 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10610,7 +10610,6 @@ "413a3ba113": "冲突", "27a50fe970": "评审冲突", "f6cb48b6fe": "用AI解决", - "3eeccbb221": "已解决的文件在离开实时冲突状态后会恢复正常更改。", "c321542ee2": "删除第 {{value0}} 行的注释", "b656381c18": "删除注释", "c085946bda": "复制行 {{value0}} 上的注释", diff --git a/src/renderer/src/lib/worktree-git-identity-display.test.ts b/src/renderer/src/lib/worktree-git-identity-display.test.ts index a48c5cb9d85..e1220e0040e 100644 --- a/src/renderer/src/lib/worktree-git-identity-display.test.ts +++ b/src/renderer/src/lib/worktree-git-identity-display.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { getDetachedHeadTooltip, getWorktreeGitIdentityDisplay, + getWorktreeGitOperationIdentityDisplay, shortGitHead } from './worktree-git-identity-display' @@ -51,3 +52,55 @@ describe('detached HEAD copy', () => { ) }) }) + +describe('identity while an operation replays commits', () => { + const midRebase = { branch: '', head: '285883d1c0ffee00', conflictOperation: 'rebase' as const } + + it('names the branch being rebased instead of collapsing to Detached HEAD', () => { + expect( + getWorktreeGitOperationIdentityDisplay({ + ...midRebase, + operationHeadName: 'refs/heads/triage-e2e' + }) + ).toEqual({ + kind: 'operation', + operation: 'rebase', + branchName: 'triage-e2e', + shortHead: '285883d', + head: '285883d1c0ffee00' + }) + }) + + // Wire compatibility: an old host omits operationProgress, so no head-name reaches us. + it('falls back to the plain detached identity when the host did not name the branch', () => { + expect(getWorktreeGitOperationIdentityDisplay({ ...midRebase })).toMatchObject({ + kind: 'detached', + shortHead: '285883d' + }) + expect( + getWorktreeGitOperationIdentityDisplay({ ...midRebase, operationHeadName: ' ' }) + ).toMatchObject({ kind: 'detached' }) + }) + + it('keeps the plain branch identity when nothing is running', () => { + expect( + getWorktreeGitOperationIdentityDisplay({ + branch: 'refs/heads/main', + head: 'abc1234', + conflictOperation: 'unknown', + operationHeadName: 'refs/heads/triage-e2e' + }) + ).toEqual({ kind: 'branch', branchName: 'main' }) + }) + + it('names a merge and a cherry-pick too, not just a rebase', () => { + expect( + getWorktreeGitOperationIdentityDisplay({ + branch: 'refs/heads/main', + head: 'abc1234', + conflictOperation: 'merge', + operationHeadName: 'refs/heads/main' + }) + ).toMatchObject({ kind: 'operation', operation: 'merge', branchName: 'main' }) + }) +}) diff --git a/src/renderer/src/lib/worktree-git-identity-display.ts b/src/renderer/src/lib/worktree-git-identity-display.ts index b2a89dbeca6..9d26280af92 100644 --- a/src/renderer/src/lib/worktree-git-identity-display.ts +++ b/src/renderer/src/lib/worktree-git-identity-display.ts @@ -1,3 +1,5 @@ +import type { GitConflictOperation } from '../../../shared/git-status-types' + export type WorktreeGitIdentityDisplay = | { kind: 'branch' @@ -10,6 +12,17 @@ export type WorktreeGitIdentityDisplay = sourceControlLabel: string tooltip: string } + // Why: git detaches HEAD while it replays commits, so `branch` is empty and the + // plain identity collapses to "Detached HEAD · " — true, but it hides the + // branch the user is actually rebasing. Kept as structured data (no prose) so + // the label and tooltip stay translatable at the render site. + | { + kind: 'operation' + operation: Exclude + branchName: string + shortHead: string + head: string + } export function shortGitHead(head: string | null | undefined): string { return (head ?? '').trim().slice(0, 7) @@ -41,3 +54,35 @@ export function getWorktreeGitIdentityDisplay(input: { tooltip: getDetachedHeadTooltip(shortHead) } } + +/** + * Identity while a merge/rebase/cherry-pick is in flight. Falls back to the plain + * identity when nothing is running, or when the host could not name the branch + * being replayed — an old host omits `operationHeadName`, and guessing a name is + * worse than showing today's detached label. + */ +export function getWorktreeGitOperationIdentityDisplay(input: { + branch?: string | null + head?: string | null + conflictOperation: GitConflictOperation + operationHeadName?: string | null +}): WorktreeGitIdentityDisplay | null { + const plain = getWorktreeGitIdentityDisplay(input) + if (input.conflictOperation === 'unknown') { + return plain + } + const branchName = (input.operationHeadName ?? '').replace(/^refs\/heads\//, '').trim() + if (!branchName) { + // Why: mid-rebase `branch` is empty, but a merge/cherry-pick keeps HEAD on its + // branch — that name is still the honest answer when head-name is unavailable. + return plain + } + const head = (input.head ?? '').trim() + return { + kind: 'operation', + operation: input.conflictOperation, + branchName, + shortHead: shortGitHead(head), + head + } +} diff --git a/src/renderer/src/runtime/runtime-git-client-api-contract.test.ts b/src/renderer/src/runtime/runtime-git-client-api-contract.test.ts index 1d40de41e96..33954b25106 100644 --- a/src/renderer/src/runtime/runtime-git-client-api-contract.test.ts +++ b/src/renderer/src/runtime/runtime-git-client-api-contract.test.ts @@ -10,6 +10,9 @@ const PUBLIC_RUNTIME_GIT_CLIENT_FUNCTIONS = [ 'cancelRuntimeGenerateCommitMessage', 'cancelRuntimeGeneratePullRequestFields', 'commitRuntimeGit', + 'continueRuntimeGitCherryPick', + 'continueRuntimeGitMerge', + 'continueRuntimeGitRebase', 'discardRuntimeGitPath', 'discoverRuntimeCommitMessageModels', 'fastForwardRuntimeGit', diff --git a/src/renderer/src/runtime/runtime-git-client-merge.test.ts b/src/renderer/src/runtime/runtime-git-client-merge.test.ts index 826ff417a00..6f18ebf1505 100644 --- a/src/renderer/src/runtime/runtime-git-client-merge.test.ts +++ b/src/renderer/src/runtime/runtime-git-client-merge.test.ts @@ -3,11 +3,22 @@ import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest } from './runtime-compatibility-test-fixture' -import { abortRuntimeGitMerge, abortRuntimeGitRebase } from './runtime-git-client' +import { + abortRuntimeGitMerge, + abortRuntimeGitRebase, + continueRuntimeGitCherryPick, + continueRuntimeGitMerge, + continueRuntimeGitRebase +} from './runtime-git-client' import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' const gitAbortMerge = vi.fn() const gitAbortRebase = vi.fn() +const gitSequencer = { + continueMerge: vi.fn(), + continueRebase: vi.fn(), + continueCherryPick: vi.fn() +} const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() const runtimeCall = vi.fn() @@ -16,6 +27,10 @@ beforeEach(() => { clearRuntimeCompatibilityCacheForTests() gitAbortMerge.mockReset() gitAbortRebase.mockReset() + for (const mock of Object.values(gitSequencer)) { + mock.mockReset() + mock.mockResolvedValue(undefined) + } runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() runtimeCall.mockReset() @@ -24,7 +39,7 @@ beforeEach(() => { }) vi.stubGlobal('window', { api: { - git: { abortMerge: gitAbortMerge, abortRebase: gitAbortRebase }, + git: { abortMerge: gitAbortMerge, abortRebase: gitAbortRebase, ...gitSequencer }, runtime: { call: runtimeCall }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } @@ -82,6 +97,53 @@ describe('runtime git client merge operations', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + const SEQUENCER_CASES = [ + [continueRuntimeGitMerge, 'continueMerge', 'git.continueMerge'], + [continueRuntimeGitRebase, 'continueRebase', 'git.continueRebase'], + [continueRuntimeGitCherryPick, 'continueCherryPick', 'git.continueCherryPick'] + ] as const + + it.each(SEQUENCER_CASES)('uses local git IPC for %#: %s', async (run, apiMethod) => { + await run({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }) + + expect(gitSequencer[apiMethod]).toHaveBeenCalledWith({ + connectionId: 'ssh-1', + worktreePath: '/repo' + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it.each(SEQUENCER_CASES)( + 'routes %# through the active runtime as %s', + async (run, apiMethod, rpcMethod) => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { success: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await run({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: rpcMethod, + params: { worktree: 'id:wt-1' }, + timeoutMs: 30_000 + }) + expect(gitSequencer[apiMethod]).not.toHaveBeenCalled() + } + ) + it('routes abort rebase through the active runtime', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 2020fa43bc9..887d0e0e534 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -25,6 +25,9 @@ import { abortRuntimeGitMerge as abortRuntimeGitMergeImplementation, abortRuntimeGitRebase as abortRuntimeGitRebaseImplementation, commitRuntimeGit as commitRuntimeGitImplementation, + continueRuntimeGitCherryPick as continueRuntimeGitCherryPickImplementation, + continueRuntimeGitMerge as continueRuntimeGitMergeImplementation, + continueRuntimeGitRebase as continueRuntimeGitRebaseImplementation, fastForwardRuntimeGit as fastForwardRuntimeGitImplementation, fetchRuntimeGit as fetchRuntimeGitImplementation, getRuntimeGitUpstreamStatus as getRuntimeGitUpstreamStatusImplementation, @@ -62,6 +65,9 @@ export const getRuntimeGitHistory = getRuntimeGitHistoryImplementation export const getRuntimeGitConflictOperation = getRuntimeGitConflictOperationImplementation export const abortRuntimeGitMerge = abortRuntimeGitMergeImplementation export const abortRuntimeGitRebase = abortRuntimeGitRebaseImplementation +export const continueRuntimeGitMerge = continueRuntimeGitMergeImplementation +export const continueRuntimeGitRebase = continueRuntimeGitRebaseImplementation +export const continueRuntimeGitCherryPick = continueRuntimeGitCherryPickImplementation export const getRuntimeGitDiff = getRuntimeGitDiffImplementation export const getRuntimeGitBranchCompare = getRuntimeGitBranchCompareImplementation export const getRuntimeGitCommitCompare = getRuntimeGitCommitCompareImplementation diff --git a/src/renderer/src/runtime/runtime-git-sync-client.ts b/src/renderer/src/runtime/runtime-git-sync-client.ts index c4318371f1f..5d8efd19312 100644 --- a/src/renderer/src/runtime/runtime-git-sync-client.ts +++ b/src/renderer/src/runtime/runtime-git-sync-client.ts @@ -40,6 +40,57 @@ export async function abortRuntimeGitRebase(context: RuntimeGitContext): Promise ) } +export async function continueRuntimeGitMerge(context: RuntimeGitContext): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.continueMerge({ + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.continueMerge', + { worktree: toRuntimeWorktreeSelector(context.worktreeId) }, + { timeoutMs: 30_000 } + ) +} + +export async function continueRuntimeGitRebase(context: RuntimeGitContext): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.continueRebase({ + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.continueRebase', + { worktree: toRuntimeWorktreeSelector(context.worktreeId) }, + { timeoutMs: 30_000 } + ) +} + +export async function continueRuntimeGitCherryPick(context: RuntimeGitContext): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.continueCherryPick({ + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.continueCherryPick', + { worktree: toRuntimeWorktreeSelector(context.worktreeId) }, + { timeoutMs: 30_000 } + ) +} + export async function getRuntimeGitUpstreamStatus( context: RuntimeGitContext, pushTarget?: GitPushTarget diff --git a/src/renderer/src/store/slices/editor-git-operation-progress.test.ts b/src/renderer/src/store/slices/editor-git-operation-progress.test.ts new file mode 100644 index 00000000000..8b604765d39 --- /dev/null +++ b/src/renderer/src/store/slices/editor-git-operation-progress.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import { createEditorStore } from './editor-slice-test-harness' + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/close-mirrored-editor-tab', () => ({ + notifyHostOfMirroredEditorClose: vi.fn() +})) + +const progress = { + headName: 'triage-e2e', + onto: 'origin/main', + currentStep: 3, + totalSteps: 7, + commitSubject: 'ci: split the e2e shards', + stoppedBy: 'pick' as const +} + +describe('git operation progress in the editor store', () => { + it('publishes the progress a host reported with a rebase', () => { + const store = createEditorStore() + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'rebase', + entries: [], + operationProgress: progress + }) + + expect(store.getState().gitOperationProgressByWorktree['wt-1']).toEqual(progress) + }) + + // Wire compatibility: a host that predates the field omits it. Absent means unknown, + // and the renderer must see undefined rather than a zeroed placeholder. + it('records nothing at all when the host omitted the field', () => { + const store = createEditorStore() + + store.getState().setGitStatus('wt-old-host', { + conflictOperation: 'rebase', + entries: [] + }) + + expect(store.getState().gitOperationProgressByWorktree).not.toHaveProperty('wt-old-host') + expect(store.getState().gitOperationProgressByWorktree['wt-old-host']).toBeUndefined() + // The operation itself still lands, so the banner degrades rather than disappearing. + expect(store.getState().gitConflictOperationByWorktree['wt-old-host']).toBe('rebase') + }) + + it('advances the step as the rebase moves on', () => { + const store = createEditorStore() + store.getState().setGitStatus('wt-1', { + conflictOperation: 'rebase', + entries: [], + operationProgress: progress + }) + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'rebase', + entries: [], + operationProgress: { ...progress, currentStep: 4 } + }) + + expect(store.getState().gitOperationProgressByWorktree['wt-1']?.currentStep).toBe(4) + }) + + it('drops the progress once the operation ends', () => { + const store = createEditorStore() + store.getState().setGitStatus('wt-1', { + conflictOperation: 'rebase', + entries: [], + operationProgress: progress + }) + + store.getState().setGitStatus('wt-1', { conflictOperation: 'unknown', entries: [] }) + + expect(store.getState().gitOperationProgressByWorktree).not.toHaveProperty('wt-1') + }) + + it('drops the progress when a non-active worktree clears its operation', () => { + const store = createEditorStore() + store.getState().setGitStatus('wt-2', { + conflictOperation: 'rebase', + entries: [], + operationProgress: progress + }) + + store.getState().setConflictOperation('wt-2', 'unknown') + + expect(store.getState().gitOperationProgressByWorktree).not.toHaveProperty('wt-2') + }) + + // A capped snapshot never reads the rebase state dir, so its silence is not evidence. + it('keeps the last known progress across a capped status snapshot', () => { + const store = createEditorStore() + store.getState().setGitStatus('wt-huge', { + conflictOperation: 'rebase', + entries: [], + operationProgress: progress + }) + + store.getState().setGitStatus('wt-huge', { + conflictOperation: 'rebase', + entries: [{ path: 'a.ts', status: 'untracked', area: 'untracked' }], + didHitLimit: true, + statusLength: 2 + }) + + expect(store.getState().gitOperationProgressByWorktree['wt-huge']).toEqual(progress) + }) +}) diff --git a/src/renderer/src/store/slices/editor/actions/git-status-actions.ts b/src/renderer/src/store/slices/editor/actions/git-status-actions.ts index ed874721663..63200db3324 100644 --- a/src/renderer/src/store/slices/editor/actions/git-status-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/git-status-actions.ts @@ -5,6 +5,10 @@ import { areTrackedConflictMapsEqual, reconcileOpenFilesForStatus } from '../git/git-status-reconciliation' +import { + areGitOperationProgressEqual, + resolveNextGitOperationProgress +} from '../git/operation-progress-state' import { branchCompareMatchesStatusHead, createLoadingBranchCompareSummary, @@ -18,6 +22,7 @@ export function createGitStatusActions( EditorSlice, | 'gitStatusByWorktree' | 'gitStatusHeadByWorktree' + | 'gitOperationProgressByWorktree' | 'gitStatusHugeByWorktree' | 'gitBranchLineTotalByWorktree' | 'gitIgnoredPathsByWorktree' @@ -34,6 +39,7 @@ export function createGitStatusActions( gitBranchLineTotalByWorktree: {}, gitIgnoredPathsByWorktree: {}, gitConflictOperationByWorktree: {}, + gitOperationProgressByWorktree: {}, trackedConflictPathsByWorktree: {}, trackConflictPath: (worktreeId, path, conflictKind) => set((s) => { @@ -160,6 +166,17 @@ export function createGitStatusActions( (prevBranchLineTotal.generated?.removed ?? null) === (nextBranchLineTotal.generated?.removed ?? null)) + const prevOperationProgress = s.gitOperationProgressByWorktree?.[worktreeId] ?? null + const nextOperationProgress = resolveNextGitOperationProgress({ + incoming: status.operationProgress, + previous: prevOperationProgress, + statusIsComplete + }) + const operationProgressUnchanged = areGitOperationProgressEqual( + prevOperationProgress, + nextOperationProgress + ) + const prevStatusHead = s.gitStatusHeadByWorktree[worktreeId] const nextStatusHead = getKnownGitHead(status.head) const statusHeadUnchanged = prevStatusHead === nextStatusHead @@ -180,6 +197,7 @@ export function createGitStatusActions( ignoredUnchanged && hugeUnchanged && branchLineTotalUnchanged && + operationProgressUnchanged && statusHeadUnchanged && !shouldInvalidateBranchCompare ) { @@ -206,6 +224,16 @@ export function createGitStatusActions( return copy })() + const nextOperationProgressMap = operationProgressUnchanged + ? s.gitOperationProgressByWorktree + : nextOperationProgress + ? { ...s.gitOperationProgressByWorktree, [worktreeId]: nextOperationProgress } + : (() => { + const copy = { ...s.gitOperationProgressByWorktree } + delete copy[worktreeId] + return copy + })() + const nextStatusHeadMap = statusHeadUnchanged ? s.gitStatusHeadByWorktree : nextStatusHead @@ -230,6 +258,7 @@ export function createGitStatusActions( gitStatusHugeByWorktree: nextHugeMap, gitBranchLineTotalByWorktree: nextBranchLineTotalMap, gitStatusHeadByWorktree: nextStatusHeadMap, + gitOperationProgressByWorktree: nextOperationProgressMap, gitStatusByWorktree: statusUnchanged ? s.gitStatusByWorktree : { ...s.gitStatusByWorktree, [worktreeId]: nextEntries }, @@ -258,11 +287,17 @@ export function createGitStatusActions( ? {} : s.trackedConflictPathsByWorktree[worktreeId] const trackedUnchanged = nextTracked === s.trackedConflictPathsByWorktree[worktreeId] + // Why: progress describes the operation that just ended; leaving it would keep a stale step meter. + const nextProgress = { ...s.gitOperationProgressByWorktree } + if (operation === 'unknown') { + delete nextProgress[worktreeId] + } return { gitConflictOperationByWorktree: { ...s.gitConflictOperationByWorktree, [worktreeId]: operation }, + ...(operation === 'unknown' ? { gitOperationProgressByWorktree: nextProgress } : {}), ...(trackedUnchanged ? {} : { diff --git a/src/renderer/src/store/slices/editor/git/operation-progress-state.ts b/src/renderer/src/store/slices/editor/git/operation-progress-state.ts new file mode 100644 index 00000000000..c7dcd1a4410 --- /dev/null +++ b/src/renderer/src/store/slices/editor/git/operation-progress-state.ts @@ -0,0 +1,41 @@ +import type { GitOperationProgress } from '../../../../../../shared/git-status-types' + +export function areGitOperationProgressEqual( + a: GitOperationProgress | null, + b: GitOperationProgress | null +): boolean { + if (a === b) { + return true + } + if (!a || !b) { + return false + } + return ( + a.headName === b.headName && + a.onto === b.onto && + a.currentStep === b.currentStep && + a.totalSteps === b.totalSteps && + a.commitSubject === b.commitSubject && + a.stoppedBy === b.stoppedBy + ) +} + +/** + * Why: a capped ("too many changes") snapshot never reads the rebase state dir, so + * treating its absent progress as "no progress" would blank a live step meter + * between polls. Only a complete snapshot may clear it. + */ +export function resolveNextGitOperationProgress({ + incoming, + previous, + statusIsComplete +}: { + incoming: GitOperationProgress | undefined + previous: GitOperationProgress | null + statusIsComplete: boolean +}): GitOperationProgress | null { + if (!statusIsComplete) { + return previous + } + return incoming ?? null +} diff --git a/src/renderer/src/store/slices/editor/types/editor-git-slice.ts b/src/renderer/src/store/slices/editor/types/editor-git-slice.ts index a74763c9c6e..10a229e89aa 100644 --- a/src/renderer/src/store/slices/editor/types/editor-git-slice.ts +++ b/src/renderer/src/store/slices/editor/types/editor-git-slice.ts @@ -8,6 +8,7 @@ import type { GitBranchLineTotal, GitConflictKind, GitConflictOperation, + GitOperationProgress, GitStatusEntry, GitStatusResult, GitUpstreamStatus @@ -32,6 +33,9 @@ export type EditorGitSlice = { gitBranchLineTotalByWorktree: Record gitIgnoredPathsByWorktree: Record gitConflictOperationByWorktree: Record + // Why: absent means the host never reported progress (old host, capped snapshot, + // unreadable state dir) — the banner must degrade, not render "step 0 of 0". + gitOperationProgressByWorktree: Record trackedConflictPathsByWorktree: Record> trackConflictPath: (worktreeId: string, path: string, conflictKind: GitConflictKind) => void setGitStatus: (worktreeId: string, status: GitStatusResult) => void diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts index dee9ebb7f84..4ab62c1176c 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts @@ -40,6 +40,7 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [ 'gitBranchLineTotalByWorktree', 'gitIgnoredPathsByWorktree', 'gitConflictOperationByWorktree', + 'gitOperationProgressByWorktree', 'trackedConflictPathsByWorktree', 'gitBranchChangesByWorktree', 'gitBranchCompareSummaryByWorktree', diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index a12cc991468..e02facec2ba 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -90,6 +90,8 @@ export function applyRemoveWorktreeSuccessState( delete nextGitIgnoredPathsByWorktree[worktreeId] const nextGitConflictOperationByWorktree = { ...s.gitConflictOperationByWorktree } delete nextGitConflictOperationByWorktree[worktreeId] + const nextGitOperationProgressByWorktree = { ...s.gitOperationProgressByWorktree } + delete nextGitOperationProgressByWorktree[worktreeId] const nextTrackedConflictPathsByWorktree = { ...s.trackedConflictPathsByWorktree } delete nextTrackedConflictPathsByWorktree[worktreeId] const nextGitBranchChangesByWorktree = { ...s.gitBranchChangesByWorktree } @@ -253,6 +255,7 @@ export function applyRemoveWorktreeSuccessState( gitBranchLineTotalByWorktree: nextGitBranchLineTotalByWorktree, gitIgnoredPathsByWorktree: nextGitIgnoredPathsByWorktree, gitConflictOperationByWorktree: nextGitConflictOperationByWorktree, + gitOperationProgressByWorktree: nextGitOperationProgressByWorktree, trackedConflictPathsByWorktree: nextTrackedConflictPathsByWorktree, gitBranchChangesByWorktree: nextGitBranchChangesByWorktree, gitBranchCompareSummaryByWorktree: nextGitBranchCompareSummaryByWorktree, diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 87becf6b343..9f0c573d51a 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -156,6 +156,7 @@ export function buildWorktreePurgeState( gitBranchLineTotalByWorktree: omitByWorktree(s.gitBranchLineTotalByWorktree), gitIgnoredPathsByWorktree: omitByWorktree(s.gitIgnoredPathsByWorktree), gitConflictOperationByWorktree: omitByWorktree(s.gitConflictOperationByWorktree), + gitOperationProgressByWorktree: omitByWorktree(s.gitOperationProgressByWorktree), trackedConflictPathsByWorktree: omitByWorktree(s.trackedConflictPathsByWorktree), gitBranchChangesByWorktree: omitByWorktree(s.gitBranchChangesByWorktree), gitBranchCompareSummaryByWorktree: omitByWorktree(s.gitBranchCompareSummaryByWorktree), diff --git a/src/shared/git-rebase-progress.test.ts b/src/shared/git-rebase-progress.test.ts new file mode 100644 index 00000000000..60f4e2e4994 --- /dev/null +++ b/src/shared/git-rebase-progress.test.ts @@ -0,0 +1,186 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readGitRebaseProgress } from './git-rebase-progress' + +// Real files, not fs mocks: the on-disk shapes are the contract being tested. +let gitDir: string + +async function writeState(dirName: string, files: Record): Promise { + const dir = path.join(gitDir, dirName) + await mkdir(dir, { recursive: true }) + for (const [name, contents] of Object.entries(files)) { + await writeFile(path.join(dir, name), contents, 'utf-8') + } +} + +beforeEach(async () => { + gitDir = await mkdtemp(path.join(tmpdir(), 'rebase-progress-')) +}) + +afterEach(async () => { + await rm(gitDir, { recursive: true, force: true }) +}) + +describe('readGitRebaseProgress — rebase-merge', () => { + it('reads every field and strips refs/heads/ off the branch', async () => { + await writeState('rebase-merge', { + msgnum: '2\n', + end: '5\n', + 'head-name': 'refs/heads/feature/topic\n', + onto: 'f5f9deb5e5967498c3b7a2a8ba5842ee6e65b20b\n', + message: 'add c as mine\n\nbody line\n', + done: 'pick f5f9deb5e5967498c3b7a2a8ba5842ee6e65b20b # topic: add c as mine\n' + }) + + expect(await readGitRebaseProgress(gitDir)).toEqual({ + headName: 'feature/topic', + onto: 'f5f9deb5e5967498c3b7a2a8ba5842ee6e65b20b', + currentStep: 2, + totalSteps: 5, + commitSubject: 'add c as mine', + stoppedBy: 'pick' + }) + }) + + it('maps the last done line command to stoppedBy', async () => { + await writeState('rebase-merge', { + done: [ + 'pick f5f9deb5e5967498c3b7a2a8ba5842ee6e65b20b # first', + 'edit cdc8a8f9e4ebfa7a67e97f0d2b8e2f6b9dfd0516 # topic commit 2', + '' + ].join('\n'), + message: 'topic commit 2\n' + }) + + expect((await readGitRebaseProgress(gitDir))?.stoppedBy).toBe('edit') + }) + + it('maps a break line to stoppedBy break and omits the subject', async () => { + await writeState('rebase-merge', { + done: 'pick f5f9deb5e5967498c3b7a2a8ba5842ee6e65b20b # first\nbreak\n' + }) + + const progress = await readGitRebaseProgress(gitDir) + + expect(progress).toEqual({ stoppedBy: 'break' }) + expect(progress?.commitSubject).toBeUndefined() + }) + + it('maps squash and other todo commands to pick', async () => { + await writeState('rebase-merge', { + done: 'squash cdc8a8f9e4ebfa7a67e97f0d2b8e2f6b9dfd0516 # squashed\n' + }) + + expect((await readGitRebaseProgress(gitDir))?.stoppedBy).toBe('pick') + }) + + it('falls back to the done-line subject when message is absent', async () => { + await writeState('rebase-merge', { + msgnum: '3', + end: '4', + done: 'edit cdc8a8f9e4ebfa7a67e97f0d2b8e2f6b9dfd0516 # topic commit 2\n' + }) + + expect(await readGitRebaseProgress(gitDir)).toEqual({ + currentStep: 3, + totalSteps: 4, + commitSubject: 'topic commit 2', + stoppedBy: 'edit' + }) + }) + + it('parses a done-line subject that has no # separator', async () => { + await writeState('rebase-merge', { + done: 'pick cdc8a8f9e4ebfa7a67e97f0d2b8e2f6b9dfd0516 topic commit 2\n' + }) + + expect((await readGitRebaseProgress(gitDir))?.commitSubject).toBe('topic commit 2') + }) + + it('omits stoppedBy when done is empty', async () => { + await writeState('rebase-merge', { done: '\n\n', 'head-name': 'refs/heads/main\n' }) + + expect(await readGitRebaseProgress(gitDir)).toEqual({ headName: 'main' }) + }) + + it('omits both step fields when msgnum/end are missing', async () => { + await writeState('rebase-merge', { + 'head-name': 'refs/heads/main\n', + onto: 'deadbeef', + message: 'still going\n' + }) + + const progress = await readGitRebaseProgress(gitDir) + + expect(progress?.currentStep).toBeUndefined() + expect(progress?.totalSteps).toBeUndefined() + expect(progress).toEqual({ headName: 'main', onto: 'deadbeef', commitSubject: 'still going' }) + }) + + it.each(['abc', '', '-1', '1.5'])( + 'omits both step fields when msgnum is %j', + async (msgnum: string) => { + await writeState('rebase-merge', { msgnum, end: '5', 'head-name': 'refs/heads/main' }) + + const progress = await readGitRebaseProgress(gitDir) + + expect(progress?.currentStep).toBeUndefined() + expect(progress?.totalSteps).toBeUndefined() + } + ) + + it('omits a detached HEAD head-name', async () => { + await writeState('rebase-merge', { 'head-name': 'detached HEAD\n', onto: 'deadbeef' }) + + expect(await readGitRebaseProgress(gitDir)).toEqual({ onto: 'deadbeef' }) + }) +}) + +describe('readGitRebaseProgress — rebase-apply', () => { + it('reads next/last/final-commit and never reports stoppedBy', async () => { + await writeState('rebase-apply', { + next: '1\n', + last: '3\n', + 'head-name': 'refs/heads/apply-branch\n', + onto: 'cdc8a8f9e4ebfa7a67e97f0d2b8e2f6b9dfd0516\n', + 'final-commit': 'am backend subject\nmore body\n' + }) + + expect(await readGitRebaseProgress(gitDir)).toEqual({ + headName: 'apply-branch', + onto: 'cdc8a8f9e4ebfa7a67e97f0d2b8e2f6b9dfd0516', + currentStep: 1, + totalSteps: 3, + commitSubject: 'am backend subject' + }) + }) + + it('prefers rebase-merge when both directories exist', async () => { + await writeState('rebase-apply', { next: '1', last: '3', 'head-name': 'refs/heads/apply' }) + await writeState('rebase-merge', { msgnum: '2', end: '9', 'head-name': 'refs/heads/merge' }) + + expect(await readGitRebaseProgress(gitDir)).toEqual({ + headName: 'merge', + currentStep: 2, + totalSteps: 9 + }) + }) +}) + +describe('readGitRebaseProgress — no state', () => { + it('returns undefined when neither directory exists', async () => { + expect(await readGitRebaseProgress(gitDir)).toBeUndefined() + }) + + it('returns undefined for an empty state directory', async () => { + await writeState('rebase-merge', {}) + + expect(await readGitRebaseProgress(gitDir)).toBeUndefined() + }) + + it('returns undefined for a git dir that does not exist', async () => { + expect(await readGitRebaseProgress(path.join(gitDir, 'missing'))).toBeUndefined() + }) +}) diff --git a/src/shared/git-rebase-progress.ts b/src/shared/git-rebase-progress.ts new file mode 100644 index 00000000000..25db0c417af --- /dev/null +++ b/src/shared/git-rebase-progress.ts @@ -0,0 +1,133 @@ +import { readFile } from 'node:fs/promises' +import * as path from 'node:path' +import type { GitOperationProgress, GitSequencerStop } from './git-status-types' + +// Reads the rebase state directory git leaves in .git during an in-flight +// rebase/cherry-pick. Every field is best-effort: absent means unknown, never +// zero, so a partially written (or partially readable) state degrades instead +// of reporting "step 0 of 0". + +const SHA_PREFIX = /^[0-9a-f]{40}\s*/i + +async function readTrimmed(filePath: string): Promise { + try { + const trimmed = (await readFile(filePath, 'utf-8')).trim() + return trimmed.length > 0 ? trimmed : undefined + } catch { + return undefined + } +} + +/** Only a positive decimal integer counts; "0", "-1", "1.5" and "abc" are unknown. */ +function parseStep(raw: string | undefined): number | undefined { + if (!raw || !/^\d+$/.test(raw)) { + return undefined + } + const value = Number(raw) + return value > 0 ? value : undefined +} + +function parseHeadName(raw: string | undefined): string | undefined { + // Why: git writes the literal "detached HEAD" when there is no branch to name. + if (!raw || raw === 'detached HEAD') { + return undefined + } + const stripped = raw.startsWith('refs/heads/') ? raw.slice('refs/heads/'.length) : raw + return stripped.length > 0 ? stripped : undefined +} + +function parseFirstLine(raw: string | undefined): string | undefined { + const first = raw?.split('\n', 1)[0]?.trim() + return first && first.length > 0 ? first : undefined +} + +function lastDoneLine(raw: string | undefined): string | undefined { + const lines = (raw ?? '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + return lines.at(-1) +} + +function parseStoppedBy(doneLine: string | undefined): GitSequencerStop | undefined { + const command = doneLine?.split(/\s+/, 1)[0] + if (!command) { + return undefined + } + if (command === 'edit' || command === 'break') { + return command + } + // pick/squash/fixup/reword/merge/... all mean "was replaying a commit". + return 'pick' +} + +/** Subject off a todo line: `pick # subject`, with both the sha and the `# ` optional. */ +function parseDoneSubject(doneLine: string | undefined): string | undefined { + if (!doneLine) { + return undefined + } + const subject = doneLine + .replace(/^\S+\s*/, '') + .replace(SHA_PREFIX, '') + .replace(/^#\s*/, '') + .trim() + return subject.length > 0 ? subject : undefined +} + +function compactProgress(progress: GitOperationProgress): GitOperationProgress | undefined { + const populated = Object.entries(progress).filter(([, value]) => value !== undefined) + return populated.length > 0 ? (Object.fromEntries(populated) as GitOperationProgress) : undefined +} + +async function readRebaseMergeProgress(dir: string): Promise { + const [msgnum, end, headName, onto, message, done] = await Promise.all( + ['msgnum', 'end', 'head-name', 'onto', 'message', 'done'].map((name) => + readTrimmed(path.join(dir, name)) + ) + ) + const currentStep = parseStep(msgnum) + const totalSteps = parseStep(end) + const doneLine = lastDoneLine(done) + return compactProgress({ + headName: parseHeadName(headName), + onto, + // Why: a lone step number would render as "step 3 of ?"; keep them all-or-nothing. + ...(currentStep !== undefined && totalSteps !== undefined ? { currentStep, totalSteps } : {}), + // `message` is absent while paused on a `break`, so fall back to the todo line. + commitSubject: parseFirstLine(message) ?? parseDoneSubject(doneLine), + stoppedBy: parseStoppedBy(doneLine) + }) +} + +async function readRebaseApplyProgress(dir: string): Promise { + const [next, last, headName, onto, finalCommit] = await Promise.all( + ['next', 'last', 'head-name', 'onto', 'final-commit'].map((name) => + readTrimmed(path.join(dir, name)) + ) + ) + const currentStep = parseStep(next) + const totalSteps = parseStep(last) + return compactProgress({ + headName: parseHeadName(headName), + onto, + ...(currentStep !== undefined && totalSteps !== undefined ? { currentStep, totalSteps } : {}), + commitSubject: parseFirstLine(finalCommit) + // The am backend has no todo list: edit/break can't happen, so stoppedBy stays absent. + }) +} + +/** + * Never throws: an unreadable state directory yields undefined, and an + * unreadable file just omits its field. `gitDir` must be the resolved .git dir + * of the worktree, read on the host that owns it. + */ +export async function readGitRebaseProgress( + gitDir: string +): Promise { + // rebase-merge wins when both are present; an unreadable one is indistinguishable + // from an absent one here, which is why the apply read is the fallback. + return ( + (await readRebaseMergeProgress(path.join(gitDir, 'rebase-merge'))) ?? + (await readRebaseApplyProgress(path.join(gitDir, 'rebase-apply'))) + ) +} diff --git a/src/shared/git-sequencer-editor-env.test.ts b/src/shared/git-sequencer-editor-env.test.ts new file mode 100644 index 00000000000..b634db143fe --- /dev/null +++ b/src/shared/git-sequencer-editor-env.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { editorSuppressedGitEnv } from './git-sequencer-editor-env' + +describe('editorSuppressedGitEnv', () => { + it('pins GIT_EDITOR to a no-op so --continue never waits on an editor', () => { + expect(editorSuppressedGitEnv({ PATH: '/usr/bin' }, 'darwin').GIT_EDITOR).toBe('true') + }) + + it('overrides an ambient editor rather than deferring to it', () => { + expect(editorSuppressedGitEnv({ GIT_EDITOR: 'vim' }, 'linux').GIT_EDITOR).toBe('true') + }) + + it('forwards GIT_EDITOR across the WSL boundary on win32', () => { + expect(editorSuppressedGitEnv({ WSLENV: 'FOO/p' }, 'win32').WSLENV).toBe('FOO/p:GIT_EDITOR') + }) + + it('does not add GIT_EDITOR to WSLENV twice', () => { + const once = editorSuppressedGitEnv({ WSLENV: 'GIT_EDITOR' }, 'win32') + expect(once.WSLENV).toBe('GIT_EDITOR') + }) + + it('leaves WSLENV alone off win32', () => { + expect(editorSuppressedGitEnv({ PATH: '/usr/bin' }, 'darwin').WSLENV).toBeUndefined() + expect(editorSuppressedGitEnv({ WSLENV: 'FOO/p' }, 'linux').WSLENV).toBe('FOO/p') + }) + + it('does not mutate the input env', () => { + const input: NodeJS.ProcessEnv = { PATH: '/usr/bin' } + editorSuppressedGitEnv(input, 'win32') + expect(input.GIT_EDITOR).toBeUndefined() + expect(input.WSLENV).toBeUndefined() + }) +}) diff --git a/src/shared/git-sequencer-editor-env.ts b/src/shared/git-sequencer-editor-env.ts new file mode 100644 index 00000000000..25647fdea6f --- /dev/null +++ b/src/shared/git-sequencer-editor-env.ts @@ -0,0 +1,19 @@ +import { addWslEnvKeys } from './wsl-env' + +/** + * Env for sequencer steps that can open the commit-message editor + * (`merge|rebase|cherry-pick --continue`): with no terminal to close it the + * child hangs forever. The GIT_EDITOR env var beats `-c core.editor`, so it is + * the only guard a user's ambient editor config cannot override. + */ +export function editorSuppressedGitEnv( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...env, GIT_EDITOR: 'true' } + if (platform === 'win32') { + // Why: spawn env does not cross the wsl.exe boundary unless WSLENV names the key. + addWslEnvKeys(next, ['GIT_EDITOR']) + } + return next +} diff --git a/src/shared/git-status-types.ts b/src/shared/git-status-types.ts index 23b57aebbc1..5ba0551d931 100644 --- a/src/shared/git-status-types.ts +++ b/src/shared/git-status-types.ts @@ -12,6 +12,29 @@ export type GitConflictKind = export type GitConflictResolutionStatus = 'unresolved' | 'resolved_locally' export type GitConflictStatusSource = 'git' | 'session' export type GitConflictOperation = 'merge' | 'rebase' | 'cherry-pick' | 'unknown' + +// Which todo command stopped the sequencer. 'edit'/'break' mean git paused on +// purpose; 'pick' means it was replaying a commit (conflict, or an empty patch). +export type GitSequencerStop = 'edit' | 'break' | 'pick' + +// Progress of an in-flight rebase/cherry-pick, read from the rebase state +// directory (rebase-merge/ or rebase-apply/). +// +// Every field is optional and ABSENT MEANS UNKNOWN, never zero. A host that +// predates these fields omits them, so a reader must degrade to the +// operation-only banner rather than render "step 0 of 0". +export type GitOperationProgress = { + // Branch being replayed (rebase-merge/head-name), with refs/heads/ stripped. + headName?: string + // Ref or OID the sequence replays onto (rebase-merge/onto). + onto?: string + // 1-based index of the commit being replayed. Only meaningful with totalSteps. + currentStep?: number + totalSteps?: number + // Subject line of the commit being replayed. + commitSubject?: string + stoppedBy?: GitSequencerStop +} export type GitSubmoduleStatus = { commitChanged: boolean trackedChanges: boolean @@ -85,6 +108,9 @@ export type GitStatusResult = { // Only computed when the request carried a merge-base OID (the renderer's // visibility gate), and omitted — never zeroed — whenever it cannot be trusted. branchLineTotal?: GitBranchLineTotal + // Absent whenever conflictOperation is 'unknown', when the state directory + // cannot be read, and on every host that predates this field. + operationProgress?: GitOperationProgress } // Why: when hasUpstream is false, ahead/behind are placeholder zeros, not a