mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
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.
This commit is contained in:
@@ -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 <sha> # 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).
|
||||
@@ -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<string, string | undefined> = {
|
||||
merge: 'git.abortMerge',
|
||||
rebase: 'git.abortRebase'
|
||||
}
|
||||
|
||||
const CONTINUE_METHODS: Record<string, string | undefined> = {
|
||||
merge: 'git.continueMerge',
|
||||
rebase: 'git.continueRebase',
|
||||
'cherry-pick': 'git.continueCherryPick'
|
||||
}
|
||||
|
||||
type RunGitAction = (
|
||||
actionId: string,
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => Promise<void>
|
||||
|
||||
/** Abort / Continue controls on the conflict banner. */
|
||||
export function useMobileConflictSequencerRunners(runGitAction: RunGitAction) {
|
||||
const runForOperation = useCallback(
|
||||
async (prefix: string, methods: Record<string, string | undefined>, 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]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> }
|
||||
type SendGitRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
|
||||
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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await runSequencerAction(['merge', '--continue'], worktreePath, options)
|
||||
}
|
||||
|
||||
export async function continueRebase(
|
||||
worktreePath: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
await runSequencerAction(['rebase', '--continue'], worktreePath, options)
|
||||
}
|
||||
|
||||
export async function continueCherryPick(
|
||||
worktreePath: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
await runSequencerAction(['cherry-pick', '--continue'], worktreePath, options)
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 (
|
||||
|
||||
@@ -45,6 +45,9 @@ export type IGitProvider = {
|
||||
detectConflictOperation(worktreePath: string): Promise<GitConflictOperation>
|
||||
abortMerge(worktreePath: string): Promise<void>
|
||||
abortRebase(worktreePath: string): Promise<void>
|
||||
continueMerge(worktreePath: string): Promise<void>
|
||||
continueRebase(worktreePath: string): Promise<void>
|
||||
continueCherryPick(worktreePath: string): Promise<void>
|
||||
checkoutBranch(worktreePath: string, branch: string): Promise<void>
|
||||
listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }>
|
||||
getBranchCompare(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -61,6 +61,37 @@ export class SshGitProvider extends SshGitWorktreeProvider implements IGitProvid
|
||||
return result as { stdout: string; stderr: string }
|
||||
}
|
||||
|
||||
async continueMerge(worktreePath: string): Promise<void> {
|
||||
await this.sequencerRequest('git.continueMerge', worktreePath, 'continue a merge')
|
||||
}
|
||||
|
||||
async continueRebase(worktreePath: string): Promise<void> {
|
||||
await this.sequencerRequest('git.continueRebase', worktreePath, 'continue a rebase')
|
||||
}
|
||||
|
||||
async continueCherryPick(worktreePath: string): Promise<void> {
|
||||
await this.sequencerRequest('git.continueCherryPick', worktreePath, 'continue a cherry-pick')
|
||||
}
|
||||
|
||||
private async sequencerRequest(
|
||||
method: string,
|
||||
worktreePath: string,
|
||||
action: string
|
||||
): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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'] =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>) => Promise<void>,
|
||||
deviceToken: string
|
||||
): Promise<void> {
|
||||
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<string, unknown>[],
|
||||
mocks: ReturnType<typeof createMobileRpcSurfaceRuntime>['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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,6 +9,9 @@ export type GitOperationApi = {
|
||||
appendGitignore: (args: { worktreePath: string; folderName: string }) => Promise<boolean>
|
||||
abortMerge: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
|
||||
abortRebase: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
|
||||
continueMerge: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
|
||||
continueRebase: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
|
||||
continueCherryPick: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
|
||||
fetch: (args: {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
|
||||
@@ -3641,6 +3641,12 @@ const api = {
|
||||
ipcRenderer.invoke('git:abortMerge', args),
|
||||
abortRebase: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('git:abortRebase', args),
|
||||
continueMerge: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('git:continueMerge', args),
|
||||
continueRebase: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('git:continueRebase', args),
|
||||
continueCherryPick: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('git:continueCherryPick', args),
|
||||
diff: (args: {
|
||||
worktreePath: string
|
||||
filePath: string
|
||||
|
||||
@@ -11,6 +11,7 @@ export type GitHandlerCommandOptions = {
|
||||
disableOptionalLocks?: boolean
|
||||
signal?: AbortSignal
|
||||
nonInteractive?: boolean
|
||||
suppressEditor?: boolean
|
||||
stdin?: string
|
||||
timeout?: number
|
||||
terminationBarrier?: boolean
|
||||
|
||||
@@ -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 } : {})
|
||||
|
||||
@@ -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<string> =>
|
||||
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)
|
||||
|
||||
@@ -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<GitHandlerCommandResult> {
|
||||
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<string, unknown>, 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,
|
||||
|
||||
@@ -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(
|
||||
<ConflictSummaryCard
|
||||
conflictOperation="merge"
|
||||
@@ -796,7 +796,7 @@ describe('OperationBanner', () => {
|
||||
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(
|
||||
<OperationBanner conflictOperation="merge" onAbortOperation={vi.fn()} />
|
||||
)
|
||||
@@ -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(
|
||||
<OperationBanner
|
||||
conflictOperation="rebase"
|
||||
sourceControlAiActionsVisible
|
||||
onAbortOperation={vi.fn()}
|
||||
onResolveWithAI={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<OperationBanner
|
||||
conflictOperation="cherry-pick"
|
||||
sourceControlAiActionsVisible
|
||||
onAbortOperation={vi.fn()}
|
||||
onResolveWithAI={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<OperationBanner
|
||||
conflictOperation="rebase"
|
||||
onAbortOperation={vi.fn()}
|
||||
onResolveWithAI={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<OperationBanner
|
||||
conflictOperation="rebase"
|
||||
sourceControlAiActionsVisible
|
||||
isAbortingOperation
|
||||
onAbortOperation={vi.fn()}
|
||||
onResolveWithAI={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(buttonContaining(markup, 'Resolve with AI')).toContain('disabled')
|
||||
expect(buttonContaining(markup, 'Abort rebase')).toContain('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+45
-1
@@ -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(
|
||||
<SourceControlHeaderToolbar
|
||||
@@ -79,6 +80,7 @@ function renderToolbar(options?: {
|
||||
: options.headDisplay
|
||||
}
|
||||
branchLineTotal={options?.branchLineTotal}
|
||||
conflictOperation={options?.conflictOperation}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 · <sha>"', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
+19
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+18
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+165
-140
@@ -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 (
|
||||
<div
|
||||
className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2"
|
||||
data-testid="source-control-operation-card"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-foreground" aria-live="polite">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.d7a5942e41',
|
||||
'{{value0}}: {{value1}} unresolved',
|
||||
{ value0: operationLabel, value1: unresolvedCount }
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.3eeccbb221',
|
||||
'Resolved files move back to normal changes after they leave the live conflict state.'
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<TriangleAlert className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-w-0 flex-1 text-xs font-medium text-foreground" aria-live="polite">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.d7a5942e41',
|
||||
'{{value0}}: {{value1}} unresolved',
|
||||
{ value0: conflictsHeading(conflictOperation), value1: unresolvedCount }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{sourceControlAiActionsVisible ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={isResolvingWithAI}
|
||||
onClick={onResolveWithAI}
|
||||
>
|
||||
{isResolvingWithAI ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-3.5" />
|
||||
)}
|
||||
{translate('auto.components.right.sidebar.SourceControl.f6cb48b6fe', 'Resolve with AI')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(sourceControlAiActionsVisible && 'mt-1.5', 'h-7 w-full text-xs')}
|
||||
onClick={onReview}
|
||||
>
|
||||
<GitMerge className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.SourceControl.27a50fe970', 'Review conflicts')}
|
||||
</Button>
|
||||
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
|
||||
<Button
|
||||
type="button"
|
||||
// Why: abort is the escape hatch, so use the quiet outline action instead of reading as destructive.
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1.5 h-7 w-full text-xs"
|
||||
disabled={isResolvingWithAI || isAbortingOperation}
|
||||
onClick={() => onAbortOperation(conflictOperation)}
|
||||
>
|
||||
{isAbortingOperation ? <RefreshCw className="size-3.5 animate-spin" /> : null}
|
||||
{conflictOperation === 'rebase'
|
||||
? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase')
|
||||
: translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<SourceControlOperationBannerActions
|
||||
conflictOperation={conflictOperation}
|
||||
hasUnresolvedConflicts
|
||||
sourceControlAiActionsVisible={sourceControlAiActionsVisible}
|
||||
isResolvingWithAI={isResolvingWithAI}
|
||||
isAbortingOperation={isAbortingOperation}
|
||||
isAdvancingOperation={isAdvancingOperation}
|
||||
onAbortOperation={onAbortOperation}
|
||||
onContinueOperation={onContinueOperation}
|
||||
onResolveWithAI={onResolveWithAI}
|
||||
onReviewConflicts={onReview}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Standalone card. The panel composes OperationCardShell + a body directly so the box survives a mid-flight swap. */
|
||||
export function ConflictSummaryCard(
|
||||
props: React.ComponentProps<typeof ConflictSummaryBody>
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<OperationCardShell>
|
||||
<ConflictSummaryBody {...props} />
|
||||
</OperationCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<SharedOperationCardProps> & {
|
||||
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 (
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2">
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-xs font-medium text-foreground">{label}</span>
|
||||
<span className="min-w-0 truncate text-xs font-medium text-foreground" title={heading}>
|
||||
{heading}
|
||||
</span>
|
||||
</div>
|
||||
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
|
||||
<Button
|
||||
type="button"
|
||||
// Why: abort is the escape hatch, so use the quiet outline action instead of reading as destructive.
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 h-7 w-full text-xs"
|
||||
disabled={isAbortingOperation}
|
||||
onClick={() => onAbortOperation(conflictOperation)}
|
||||
>
|
||||
{isAbortingOperation ? <RefreshCw className="size-3.5 animate-spin" /> : null}
|
||||
{conflictOperation === 'rebase'
|
||||
? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase')
|
||||
: translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<SourceControlOperationBannerActions
|
||||
conflictOperation={conflictOperation}
|
||||
sourceControlAiActionsVisible={sourceControlAiActionsVisible}
|
||||
isResolvingWithAI={isResolvingWithAI}
|
||||
isAbortingOperation={isAbortingOperation}
|
||||
isAdvancingOperation={isAdvancingOperation}
|
||||
onAbortOperation={onAbortOperation}
|
||||
onContinueOperation={onContinueOperation}
|
||||
onResolveWithAI={onResolveWithAI}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Standalone card; see ConflictSummaryCard for why the panel does not use this directly. */
|
||||
export function OperationBanner(
|
||||
props: React.ComponentProps<typeof OperationBannerBody>
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<OperationCardShell>
|
||||
<OperationBannerBody {...props} />
|
||||
</OperationCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
+47
-23
@@ -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') && (
|
||||
<div className="px-3 pb-2">
|
||||
<ConflictSummaryCard
|
||||
conflictOperation={conflictOperation}
|
||||
unresolvedCount={unresolvedConflictCount}
|
||||
sourceControlAiActionsVisible={sourceControlAiActionsVisible}
|
||||
isResolvingWithAI={false}
|
||||
isAbortingOperation={isAbortingOperation}
|
||||
onAbortOperation={onAbortOperation}
|
||||
onResolveWithAI={onResolveWithAi}
|
||||
onReview={onReviewConflicts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Why: show the operation banner when a rebase/merge/cherry-pick is in progress with no unresolved conflicts. */}
|
||||
{unresolvedConflictCount === 0 && conflictOperation !== 'unknown' && (
|
||||
<div className="px-3 pb-2">
|
||||
<OperationBanner
|
||||
conflictOperation={conflictOperation}
|
||||
isAbortingOperation={isAbortingOperation}
|
||||
onAbortOperation={onAbortOperation}
|
||||
/>
|
||||
<OperationCardShell>
|
||||
{unresolvedConflictCount > 0 ? (
|
||||
<ConflictSummaryBody
|
||||
conflictOperation={conflictOperation}
|
||||
unresolvedCount={unresolvedConflictCount}
|
||||
sourceControlAiActionsVisible={sourceControlAiActionsVisible}
|
||||
isResolvingWithAI={false}
|
||||
isAbortingOperation={isAbortingOperation}
|
||||
isAdvancingOperation={isAdvancingOperation}
|
||||
onAbortOperation={onAbortOperation}
|
||||
onContinueOperation={onContinueOperation}
|
||||
onResolveWithAI={onResolveWithAi}
|
||||
onReview={onReviewConflicts}
|
||||
/>
|
||||
) : (
|
||||
<OperationBannerBody
|
||||
conflictOperation={conflictOperation}
|
||||
sourceControlAiActionsVisible={sourceControlAiActionsVisible}
|
||||
isResolvingWithAI={false}
|
||||
isAbortingOperation={isAbortingOperation}
|
||||
isAdvancingOperation={isAdvancingOperation}
|
||||
operationProgress={operationProgress}
|
||||
onAbortOperation={onAbortOperation}
|
||||
onContinueOperation={onContinueOperation}
|
||||
onResolveWithAI={onResolveWithAi}
|
||||
/>
|
||||
)}
|
||||
</OperationCardShell>
|
||||
</div>
|
||||
)}
|
||||
{repositoryHuge && (
|
||||
|
||||
+142
@@ -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 = <RefreshCw className="size-3.5 animate-spin" />
|
||||
|
||||
/**
|
||||
* 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 : <Play className="size-3.5" />,
|
||||
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 : <Sparkles className="size-3.5" />,
|
||||
onClick: onResolveWithAI
|
||||
})
|
||||
}
|
||||
if (onReviewConflicts && hasUnresolvedConflicts) {
|
||||
actions.push({
|
||||
key: 'review',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.SourceControl.27a50fe970',
|
||||
'Review conflicts'
|
||||
),
|
||||
icon: <GitMerge className="size-3.5" />,
|
||||
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 => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="button"
|
||||
variant={variant}
|
||||
size="sm"
|
||||
className="h-7 w-full min-w-0 text-xs"
|
||||
disabled={busy}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="truncate">{action.label}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
{actions.map((action) => render(action, action === primary ? 'default' : 'outline'))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+92
@@ -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(<SourceControlContentStatus {...baseProps(0)} />)
|
||||
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(
|
||||
<SourceControlContentStatus
|
||||
{...baseProps(2)}
|
||||
operationProgress={{ ...progress, currentStep: 4 }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(<SourceControlContentStatus {...baseProps(0)} />)
|
||||
expect(container.querySelector('[data-testid="source-control-operation-card"]')).not.toBeNull()
|
||||
|
||||
rerender(
|
||||
<SourceControlContentStatus
|
||||
{...baseProps(0)}
|
||||
conflictOperation="unknown"
|
||||
operationProgress={null}
|
||||
/>
|
||||
)
|
||||
|
||||
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(<SourceControlContentStatus {...baseProps(1)} />)
|
||||
const cardBefore = container.querySelector('[data-testid="source-control-operation-card"]')
|
||||
|
||||
rerender(<SourceControlContentStatus {...baseProps(0)} />)
|
||||
|
||||
expect(container.querySelector('[data-testid="source-control-operation-card"]')).toBe(
|
||||
cardBefore
|
||||
)
|
||||
expect(container.textContent).toContain('Continue rebase')
|
||||
})
|
||||
})
|
||||
+168
@@ -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(`<button[^>]*>(?:(?!</button>).)*${label}[\\s\\S]*?</button>`)
|
||||
)
|
||||
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<React.ComponentProps<typeof OperationBanner>> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
<OperationBanner
|
||||
conflictOperation="rebase"
|
||||
sourceControlAiActionsVisible
|
||||
operationProgress={progress}
|
||||
{...handlers}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<OperationBanner
|
||||
conflictOperation="rebase"
|
||||
sourceControlAiActionsVisible
|
||||
onAbortOperation={vi.fn()}
|
||||
onResolveWithAI={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
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<React.ComponentProps<typeof ConflictSummaryCard>> = {}
|
||||
): string =>
|
||||
renderToStaticMarkup(
|
||||
<ConflictSummaryCard
|
||||
conflictOperation="rebase"
|
||||
unresolvedCount={1}
|
||||
sourceControlAiActionsVisible
|
||||
isResolvingWithAI={false}
|
||||
onReview={vi.fn()}
|
||||
{...handlers}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
+27
-3
@@ -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 · <sha>" 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,
|
||||
|
||||
+12
-59
@@ -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 (
|
||||
<DetachedHeadBadge
|
||||
display={display}
|
||||
side="bottom"
|
||||
// Why: tooltip carries the full detached explanation; keep it keyboard-reachable.
|
||||
tabIndex={0}
|
||||
className="min-w-0 max-w-full shrink"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="block min-w-0 max-w-full truncate rounded-sm font-mono text-[10.5px] font-medium text-foreground/90 outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
tabIndex={0}
|
||||
aria-label={branchAriaLabel}
|
||||
data-testid="source-control-head-identity"
|
||||
>
|
||||
{display.branchName}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6} className="max-w-72 break-all font-mono">
|
||||
{display.branchName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
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) => <ContextStat key={stat.key} stat={stat} />
|
||||
)
|
||||
const compareStatNodes = buildSourceControlCompareBaseStats(
|
||||
summary,
|
||||
displayedBaseRef,
|
||||
conflictOperation !== undefined && conflictOperation !== 'unknown'
|
||||
).map((stat) => <ContextStat key={stat.key} stat={stat} />)
|
||||
|
||||
return (
|
||||
<CompareFlowGroup flowLabel={flowLabel} className="min-w-0 text-[11px] text-muted-foreground">
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+131
@@ -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<WorktreeGitIdentityDisplay, { kind: 'operation' }>
|
||||
|
||||
/** 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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="block min-w-0 max-w-full truncate rounded-sm font-mono text-[10.5px] font-medium text-foreground/90 outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
tabIndex={0}
|
||||
aria-label={ariaLabel}
|
||||
data-testid="source-control-head-identity"
|
||||
data-operation={display.operation}
|
||||
>
|
||||
{display.branchName}
|
||||
<span className="text-amber-600 dark:text-amber-400">{` · ${qualifier}`}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6} className="max-w-72 break-all font-mono">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function HeadIdentity({
|
||||
display
|
||||
}: {
|
||||
display: WorktreeGitIdentityDisplay
|
||||
}): React.JSX.Element {
|
||||
if (display.kind === 'detached') {
|
||||
return (
|
||||
<DetachedHeadBadge
|
||||
display={display}
|
||||
side="bottom"
|
||||
// Why: tooltip carries the full detached explanation; keep it keyboard-reachable.
|
||||
tabIndex={0}
|
||||
className="min-w-0 max-w-full shrink"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (display.kind === 'operation') {
|
||||
return <OperationIdentityChip display={display} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="block min-w-0 max-w-full truncate rounded-sm font-mono text-[10.5px] font-medium text-foreground/90 outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
tabIndex={0}
|
||||
aria-label={branchAriaLabel}
|
||||
data-testid="source-control-head-identity"
|
||||
>
|
||||
{display.branchName}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6} className="max-w-72 break-all font-mono">
|
||||
{display.branchName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
const normalizedFilter = filterQuery.trim()
|
||||
@@ -320,6 +325,7 @@ export function SourceControlHeaderToolbar({
|
||||
headDisplay={headDisplay}
|
||||
manualReviewUrl={manualReviewUrl}
|
||||
branchLineTotal={branchLineTotal}
|
||||
conflictOperation={conflictOperation}
|
||||
onChangeBaseRef={onChangeBaseRef}
|
||||
onRetry={onRefreshBranchCompare}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
+8
@@ -48,7 +48,11 @@ export function useSourceControlWorktreeOperationState({
|
||||
const [abortOperationInFlightByWorktree, setAbortOperationInFlightByWorktree] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
const [advanceOperationInFlightByWorktree, setAdvanceOperationInFlightByWorktree] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
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<Record<string, boolean>>({})
|
||||
@@ -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,
|
||||
|
||||
@@ -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<GitStatusEntry, 'path' | 'status' | 'area'>
|
||||
|
||||
export const SOURCE_CONTROL_ACTION_ERROR_ENTRY_SNAPSHOT_LIMIT = 120
|
||||
|
||||
+111
@@ -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<typeof useSourceControlConflictAdvance>[0]
|
||||
|
||||
function setup(overrides: Partial<AdvanceOptions> = {}) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
+128
@@ -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<void>>
|
||||
refreshGitHistoryRef: React.RefObject<() => Promise<void>>
|
||||
setAdvanceOperationInFlightByWorktree: SourceControlWorktreeOperationState['setAdvanceOperationInFlightByWorktree']
|
||||
setRemoteActionErrors: SourceControlWorktreeOperationState['setRemoteActionErrors']
|
||||
worktreePath: string | null
|
||||
}) {
|
||||
const runAdvance = useCallback(
|
||||
async (requestedOperation: GitConflictOperation): Promise<void> => {
|
||||
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<typeof useSourceControlConflictAdvance>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -10567,7 +10567,6 @@
|
||||
"413a3ba113": "競合",
|
||||
"27a50fe970": "競合をレビューする",
|
||||
"f6cb48b6fe": "AIで解決する",
|
||||
"3eeccbb221": "解決されたファイルは、ライブ競合状態を抜けた後、通常の変更に戻ります。",
|
||||
"c321542ee2": "{{value0}} 行目のメモを削除",
|
||||
"b656381c18": "メモの削除",
|
||||
"c085946bda": "{{value0}} 行目のメモをコピーします",
|
||||
|
||||
@@ -10575,7 +10575,6 @@
|
||||
"413a3ba113": "충돌",
|
||||
"27a50fe970": "충돌 리뷰",
|
||||
"f6cb48b6fe": "AI로 해결",
|
||||
"3eeccbb221": "해결된 파일은 라이브 충돌 상태를 벗어나면 다시 일반 변경 사항으로 돌아갑니다.",
|
||||
"c321542ee2": "{{value0}}행의 메모 삭제",
|
||||
"b656381c18": "메모 삭제",
|
||||
"c085946bda": "{{value0}}행에 메모 복사",
|
||||
|
||||
@@ -10610,7 +10610,6 @@
|
||||
"413a3ba113": "冲突",
|
||||
"27a50fe970": "评审冲突",
|
||||
"f6cb48b6fe": "用AI解决",
|
||||
"3eeccbb221": "已解决的文件在离开实时冲突状态后会恢复正常更改。",
|
||||
"c321542ee2": "删除第 {{value0}} 行的注释",
|
||||
"b656381c18": "删除注释",
|
||||
"c085946bda": "复制行 {{value0}} 上的注释",
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 · <sha>" — 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<GitConflictOperation, 'unknown'>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ const PUBLIC_RUNTIME_GIT_CLIENT_FUNCTIONS = [
|
||||
'cancelRuntimeGenerateCommitMessage',
|
||||
'cancelRuntimeGeneratePullRequestFields',
|
||||
'commitRuntimeGit',
|
||||
'continueRuntimeGitCherryPick',
|
||||
'continueRuntimeGitMerge',
|
||||
'continueRuntimeGitRebase',
|
||||
'discardRuntimeGitPath',
|
||||
'discoverRuntimeCommitMessageModels',
|
||||
'fastForwardRuntimeGit',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,6 +40,57 @@ export async function abortRuntimeGitRebase(context: RuntimeGitContext): Promise
|
||||
)
|
||||
}
|
||||
|
||||
export async function continueRuntimeGitMerge(context: RuntimeGitContext): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
GitBranchLineTotal,
|
||||
GitConflictKind,
|
||||
GitConflictOperation,
|
||||
GitOperationProgress,
|
||||
GitStatusEntry,
|
||||
GitStatusResult,
|
||||
GitUpstreamStatus
|
||||
@@ -32,6 +33,9 @@ export type EditorGitSlice = {
|
||||
gitBranchLineTotalByWorktree: Record<string, GitBranchLineTotal | null>
|
||||
gitIgnoredPathsByWorktree: Record<string, string[]>
|
||||
gitConflictOperationByWorktree: Record<string, GitConflictOperation>
|
||||
// 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<string, GitOperationProgress>
|
||||
trackedConflictPathsByWorktree: Record<string, Record<string, GitConflictKind>>
|
||||
trackConflictPath: (worktreeId: string, path: string, conflictKind: GitConflictKind) => void
|
||||
setGitStatus: (worktreeId: string, status: GitStatusResult) => void
|
||||
|
||||
@@ -40,6 +40,7 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [
|
||||
'gitBranchLineTotalByWorktree',
|
||||
'gitIgnoredPathsByWorktree',
|
||||
'gitConflictOperationByWorktree',
|
||||
'gitOperationProgressByWorktree',
|
||||
'trackedConflictPathsByWorktree',
|
||||
'gitBranchChangesByWorktree',
|
||||
'gitBranchCompareSummaryByWorktree',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, string>): Promise<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<string | undefined> {
|
||||
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 <sha> # 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<GitOperationProgress | undefined> {
|
||||
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<GitOperationProgress | undefined> {
|
||||
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<GitOperationProgress | undefined> {
|
||||
// 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')))
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user