Files
orca/src/main/git/remove-worktree.test.ts
T
JinjingandOrca 772081577e Fix fork PR/MR worktree creation race via durable review-head refs (#10429)
* Fix fork PR/MR worktree creation race via durable review-head refs

When creating a fork PR/MR worktree, concurrent `git fetch origin` operations
clobber the shared FETCH_HEAD, causing the wrong commit to be checked out.
Fetch PR/MR heads into dedicated per-review refs (`refs/orca/pull/<N>`,
`refs/orca/merge-requests/<N>`) that persist and isolate each head from other
fetches. Gracefully keep the compare-base when the fetch fails but the local
ref already exists, avoiding silent fallback to the wrong branch on transient
network errors.

* Bound PR/MR head fetches with 60s timeout

Prevent PR/MR creation from hanging when a remote is stalled or
unreachable. Both GitHub and GitLab head fetches now enforce a
60-second timeout, matching the bound used in the create-path
fetch. Durable refs (refs/orca/pull/*, refs/orca/merge-requests/*)
decouple the ref from FETCH_HEAD, preserving legacy client semantics.

* test: align CI expectations with main PowerShell/sparse regressions

PR checks merge into main, which recently changed PowerShell launch args
(cwd restore after profiles) and sparse-checkout detection (require
core.sparseCheckout). Derive PowerShell spawn args from the production
resolver, mock the sparse config flag, reset shared worktree list scan
cache between tests, and stop requiring floating polls to avoid getRepos
hydration.

* Address review follow-ups on durable review-head refs

- Unify PR review-head remote selection: local and SSH GitHub paths share
  resolveGitHubReviewHeadRemote, which prefers the remote mapping to the
  hosting GitHub project (upstream before origin, matching work-item/API
  candidate order) so contributor clones fetch refs/pull from the repo
  that actually hosts the PR.
- Soft-keep durable review heads: when the PR/MR head fetch fails but
  refs/orca/pull/<N> / refs/orca/merge-requests/<iid> still resolves,
  keep the pinned SHA (warn) instead of failing resolve, mirroring the
  compare-base fallback. Extracted shared compare-base soft-keep into
  compare-base-ref-fetch.ts.
- Extract fetchGitLabMergeRequestHeadRef (local + SSH) parallel to the
  GitHub helper; bound its local fetch with the shared 60s timeout.
- Share relay-style fetch validation (positive safe-integer id, remote
  not starting with "-") between relay and local helpers via
  review-head-tracking-ref.ts; move REVIEW_HEAD_FETCH_TIMEOUT_MS there.
- Drop the githubPullRequestHeadLocalRef re-export; resolve head SHAs via
  rev-parse --verify <ref>^{commit}.
- Add GitLab anti-FETCH_HEAD regression test plus durable-head soft-keep
  and remote-selection unit tests.

Co-authored-by: Orca <help@stably.ai>

* test: supply live getRepos for terminal-retirement hydrates

Main's headless tab hydrate (#9343) skips worktree keys whose repo is not
in getRepos. Retirement tests that rebuild mobile tabs from a persisted
session now advertise the fixture repo as live so PR Checks merge stays green.

* fix(editor): extract RichMarkdownEditor props to stay under max-lines

Main's SSH external-image wiring (#10323) pushed RichMarkdownEditor.tsx over
the 400-line tsx budget, failing PR Checks lint on every merge into main.
Move the props type into a sibling module so the component stays under the
limit without disabling max-lines.

* Make durable review-head refs remote-identity scoped

Embed remote name + URL hash into refs/orca/pull|merge-requests refs to prevent soft-keep from serving wrong project's PR/MR when FETCH_HEAD is clobbered by concurrent fetch. Fetch functions now return the written ref path (writer-authoritative) so callers rev-parse exactly what was fetched, not re-derive identity. Soft-keep only applies to transient errors (timeout, network); fails hard on missing refs, auth failures, and stale relay. Relay returns localRef so client avoids re-hashing (URL normalization can disagree).

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 12:53:18 -07:00

1369 lines
43 KiB
TypeScript

/* eslint-disable max-lines -- Why: remove/list/sparse cleanup tests share one git runner
mock harness, and splitting them would duplicate setup without a clearer boundary. */
import type * as FsPromises from 'node:fs/promises'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
gitExecFileAsyncMock,
gitExecFileSyncMock,
translateWslOutputPathsMock,
statMock,
readFileMock,
resolveGitDirMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn(),
translateWslOutputPathsMock: vi.fn((output: string) => output),
statMock: vi.fn(),
readFileMock: vi.fn(),
resolveGitDirMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./status', () => ({
resolveGitDir: resolveGitDirMock,
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
}))
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
return { ...actual, stat: statMock, readFile: readFileMock }
})
import { clearGitCapabilityStateForTests } from './git-capability-state'
import {
addSparseWorktree,
assertWorktreeCleanForRemoval,
forceDeleteLocalBranch,
listWorktrees,
removeWorktree,
_resetWorktreeScanCacheForTests,
WORKTREE_LIST_TIMEOUT_MS,
WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS
} from './worktree'
// Why: detectSparseCheckout on main also requires core.sparseCheckout=true in git
// config (not just a non-empty pattern file). Unit tests that assert isSparse must
// present an enabled flag; other paths never reach this read after the pattern-file
// fast-path ENOENT.
const ENABLED_SPARSE_CHECKOUT_CONFIG = '[core]\nsparseCheckout = true\n'
beforeEach(() => {
clearGitCapabilityStateForTests()
_resetWorktreeScanCacheForTests()
})
type MockResult = {
error?: Error
stdout?: string
stderr?: string
}
function mockGitCommands(results: Record<string, MockResult>): void {
const callCounts = new Map<string, number>()
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
const key = `git ${args.join(' ')}`
const callCount = (callCounts.get(key) ?? 0) + 1
callCounts.set(key, callCount)
const lineListKey =
key === 'git worktree list --porcelain -z' ? 'git worktree list --porcelain' : ''
const result =
results[`${key}#${callCount}`] ??
results[key] ??
(lineListKey
? (results[`${lineListKey}#${callCount}`] ?? results[lineListKey])
: undefined) ??
{}
if (result.error) {
throw Object.assign(result.error, {
stdout: result.stdout ?? '',
stderr: result.stderr ?? ''
})
}
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? ''
}
})
}
function getGitCalls(): string[] {
return gitExecFileAsyncMock.mock.calls.map((call) => `git ${call[0].join(' ')}`)
}
function expectGitCallOrder(calls: string[], beforeCall: string, afterCall: string): void {
expect(calls.indexOf(beforeCall)).toBeGreaterThanOrEqual(0)
expect(calls.indexOf(afterCall)).toBeGreaterThan(calls.indexOf(beforeCall))
}
function mockSparseCheckoutEnabledConfig(): void {
readFileMock.mockImplementation(async (filePath: string) => {
const normalized = String(filePath).replaceAll('\\', '/')
// Why: linked worktrees may point at a common dir; treat missing commondir as
// "this gitdir is the common dir" so the shared config read still runs.
if (normalized.endsWith('/commondir')) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
if (normalized.endsWith('/config') || normalized.endsWith('/config.worktree')) {
return ENABLED_SPARSE_CHECKOUT_CONFIG
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
}
describe('removeWorktree', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockReset()
translateWslOutputPathsMock.mockImplementation((output: string) => output)
statMock.mockReset()
// Default: no worktree has a sparse-checkout config file. Tests that need
// sparse detection override this.
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
readFileMock.mockReset()
mockSparseCheckoutEnabledConfig()
resolveGitDirMock.mockReset()
resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
})
it('removes the worktree and deletes its local branch', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
}
})
await removeWorktree('/repo', '/repo-feature')
const calls = getGitCalls()
expect(calls).toEqual(
expect.arrayContaining(['git worktree remove /repo-feature', 'git branch -d -- feature/test'])
)
expect(calls).not.toContain('git worktree prune')
expectGitCallOrder(calls, 'git worktree remove /repo-feature', 'git branch -d -- feature/test')
})
it('preserves the branch when requested for a pre-existing local branch checkout', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
}
})
await removeWorktree('/repo', '/repo-feature', false, { deleteBranch: false })
const calls = getGitCalls()
expect(calls).toContain('git worktree remove /repo-feature')
expect(calls).not.toContain('git worktree prune')
expect(calls).not.toContain('git branch -d -- feature/test')
expect(calls).not.toContain('git branch -D -- feature/test')
})
it('skips branch deletion when another worktree still points at the branch', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
worktree /repo-feature-copy
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature-copy
HEAD def456
branch refs/heads/feature/test
`
},
'git branch -d -- feature/test': {
error: new Error(
"cannot delete branch 'feature/test' used by worktree at '/repo-feature-copy'"
)
},
'git branch -d -- feature/test#2': {
error: new Error(
"cannot delete branch 'feature/test' used by worktree at '/repo-feature-copy'"
)
}
})
await removeWorktree('/repo', '/repo-feature')
const calls = getGitCalls()
expect(calls).toEqual(
expect.arrayContaining([
'git worktree remove /repo-feature',
'git branch -d -- feature/test',
'git worktree prune'
])
)
expect(calls.filter((call) => call === 'git branch -d -- feature/test')).toHaveLength(2)
expect(calls).not.toContain('git branch -D -- feature/test')
})
it('deletes the branch after prune removes stale sibling worktree entries', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
worktree /repo-stale
HEAD 0000000
branch refs/heads/feature/test
prunable gitdir file points to non-existent location
`
},
'git worktree list --porcelain#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git branch -d -- feature/test': {
error: new Error("cannot delete branch 'feature/test' used by worktree at '/repo-stale'")
},
'git branch -d -- feature/test#2': {
stdout: ''
}
})
await removeWorktree('/repo', '/repo-feature')
const calls = getGitCalls()
expect(calls).toEqual([
'git worktree list --porcelain -z',
'git worktree remove /repo-feature',
'git branch -d -- feature/test',
'git worktree prune',
'git branch -d -- feature/test'
])
})
it('passes one --force before the worktree path for dirty-file removal', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
}
})
await removeWorktree('/repo', '/repo-feature', true)
expect(getGitCalls()).toContain('git worktree remove --force /repo-feature')
})
it('force-retries removal when git refuses a clean worktree containing an initialised submodule', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git worktree remove /repo-feature': {
error: new Error('git worktree remove failed'),
stderr: 'fatal: working trees containing submodules cannot be moved or removed'
},
'git status --porcelain --untracked-files=all': { stdout: '' }
})
await removeWorktree('/repo', '/repo-feature')
const calls = getGitCalls()
expectGitCallOrder(
calls,
'git worktree remove /repo-feature',
'git status --porcelain --untracked-files=all'
)
expectGitCallOrder(
calls,
'git status --porcelain --untracked-files=all',
'git worktree remove --force /repo-feature'
)
expect(calls).toContain('git branch -d -- feature/test')
})
it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree remove /repo-feature': {
error: new Error('git worktree remove failed'),
stderr: 'fatal: working trees containing submodules cannot be moved or removed'
},
'git status --porcelain --untracked-files=all': { stdout: ' M sub\n' }
})
await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow(
'Worktree has uncommitted or untracked changes.'
)
expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature')
})
it('does not force-retry when the caller already forced removal', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree remove --force /repo-feature': {
error: new Error('git worktree remove failed'),
stderr: 'fatal: working trees containing submodules cannot be moved or removed'
}
})
await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow()
expect(
getGitCalls().filter((call) => call === 'git worktree remove --force /repo-feature')
).toHaveLength(1)
})
it('does not force-retry unrelated non-force remove failures', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree remove /repo-feature': {
error: new Error('git worktree remove failed'),
stderr: 'fatal: contains modified or untracked files, use --force to delete it'
}
})
await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow(
'git worktree remove failed'
)
expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature')
expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all')
})
it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
locked active agent session
`
}
})
await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow(
'Worktree is locked by Git. Lock reason: active agent session.'
)
expect(getGitCalls()).not.toContain('git worktree remove /repo-feature')
})
it('does not treat dirty-file force as permission to override a lock', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
locked active agent session
`
}
})
await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow(
'Worktree is locked by Git. Lock reason: active agent session.'
)
expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature')
})
it('matches Windows worktree paths before deleting the branch', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree C:/repo
HEAD abc123
branch refs/heads/main
worktree C:/Workspaces/Delete-Branch-Ui-Test
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
stdout: `worktree C:/repo
HEAD abc123
branch refs/heads/main
`
}
})
await removeWorktree('C:\\repo', 'c:\\workspaces\\delete-branch-ui-test')
const calls = getGitCalls()
expect(calls).toEqual(
expect.arrayContaining([
'git worktree remove c:\\workspaces\\delete-branch-ui-test',
'git branch -d -- feature/test'
])
)
expect(calls).not.toContain('git worktree prune')
})
it('keeps removal successful when branch cleanup fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git branch -d -- feature/test': {
error: new Error('branch delete failed'),
stderr: 'branch delete failed'
}
})
await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({
preservedBranch: { branchName: 'feature/test', head: 'def456' }
})
expect(warnSpy).toHaveBeenCalledWith(
'[git] Preserved local branch "feature/test" after removing worktree (not fully merged)',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('deletes a squash-merged branch when merging it into the base is a no-op', async () => {
mockGitCommands({
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git worktree list --porcelain#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git branch -d -- feature/test': {
error: new Error('branch delete failed'),
stderr: 'error: the branch feature/test is not fully merged'
},
'git config --get branch.feature/test.base': {
stdout: 'refs/remotes/origin/main\n'
},
'git rev-parse --verify --quiet refs/remotes/origin/main^{commit}': {
stdout: 'base123\n'
},
'git merge-tree --write-tree base123 refs/heads/feature/test': {
stdout: 'tree123\n'
},
'git rev-parse --verify --quiet base123^{tree}': {
stdout: 'tree123\n'
}
})
await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({})
const calls = getGitCalls()
expect(calls).toContain('git branch -d -- feature/test')
expect(calls).toContain('git merge-tree --write-tree base123 refs/heads/feature/test')
expect(calls).toContain('git update-ref -d refs/heads/feature/test def456')
expect(calls).toContain('git config --remove-section branch.feature/test')
})
it('deletes a squash-merged branch with branch-only merge commits via expected head', async () => {
mockGitCommands({
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git branch -d -- feature/test': {
error: new Error('branch delete failed'),
stderr: 'error: the branch feature/test is not fully merged'
},
'git config --get branch.feature/test.base': {
stdout: 'refs/remotes/origin/main\n'
},
'git rev-parse --verify --quiet refs/remotes/origin/main^{commit}': {
stdout: 'target123\n'
},
'git merge-tree --write-tree target123 refs/heads/feature/test': {
stdout: 'merged-tree\n'
},
'git rev-parse --verify --quiet target123^{tree}': {
stdout: 'target-tree\n'
},
'git rev-list --right-only --merges --count target123...refs/heads/feature/test': {
stdout: '1\n'
},
'git merge-base target123 refs/heads/feature/test': {
stdout: 'base123\n'
},
'git diff base123 refs/heads/feature/test': {
stdout: 'branch net diff\n'
},
'git patch-id --stable#1': {
stdout: 'patch123 0000000000000000000000000000000000000000\n'
},
'git rev-list --ancestry-path --max-count=201 base123..target123': {
stdout: 'squash123\n'
},
'git show --format= squash123': {
stdout: 'squash diff\n'
},
'git patch-id --stable#2': {
stdout: 'patch123 squash123\n'
},
'git merge-tree --write-tree squash123 refs/heads/feature/test': {
stdout: 'squash-tree\n'
},
'git rev-parse --verify --quiet squash123^{tree}': {
stdout: 'squash-tree\n'
}
})
await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({})
const calls = getGitCalls()
expect(calls).toContain('git update-ref -d refs/heads/feature/test def456')
expect(calls).toContain('git config --remove-section branch.feature/test')
expect(gitExecFileAsyncMock.mock.calls).toContainEqual([
['patch-id', '--stable'],
{ cwd: '/repo', stdin: 'branch net diff\n' }
])
expect(gitExecFileAsyncMock.mock.calls).toContainEqual([
['patch-id', '--stable'],
{ cwd: '/repo', stdin: 'squash diff\n' }
])
})
it('refreshes the saved remote base before deleting a safe-delete-rejected branch', async () => {
mockGitCommands({
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git worktree list --porcelain#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git branch -d -- feature/test': {
error: new Error('branch delete failed'),
stderr: 'error: the branch feature/test is not fully merged'
},
'git config --get branch.feature/test.base': {
stdout: 'refs/remotes/origin/main\n'
},
'git remote': {
stdout: 'origin\n'
},
'git fetch --prune origin': {
stdout: ''
},
'git rev-parse --verify --quiet refs/remotes/origin/main^{commit}': {
stdout: 'base123\n'
},
'git merge-tree --write-tree base123 refs/heads/feature/test': {
stdout: 'tree123\n'
},
'git rev-parse --verify --quiet base123^{tree}': {
stdout: 'tree123\n'
}
})
await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({})
const calls = getGitCalls()
expect(calls).toContain('git fetch --prune origin')
expect(calls).toContain('git update-ref -d refs/heads/feature/test def456')
expectGitCallOrder(
calls,
'git fetch --prune origin',
'git merge-tree --write-tree base123 refs/heads/feature/test'
)
expectGitCallOrder(
calls,
'git fetch --prune origin',
'git update-ref -d refs/heads/feature/test def456'
)
})
it('preserves an already-merged branch when cleanup races after worktree removal', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockGitCommands({
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git branch -d -- feature/test': {
error: new Error('branch delete failed'),
stderr: 'error: the branch feature/test is not fully merged'
},
'git config --get branch.feature/test.base': {
stdout: 'refs/remotes/origin/main\n'
},
'git rev-parse --verify --quiet refs/remotes/origin/main^{commit}': {
stdout: 'base123\n'
},
'git rev-list --right-only --merges --count base123...refs/heads/feature/test': {
stdout: '0\n'
},
'git cherry -v base123 refs/heads/feature/test': {
stdout: '- def456 fix: already squash-merged\n'
},
'git update-ref -d refs/heads/feature/test def456': {
error: new Error('cannot lock ref')
}
})
await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({
preservedBranch: { branchName: 'feature/test', head: 'def456' }
})
expect(warnSpy).toHaveBeenCalledWith(
'[git] Failed to delete already-merged local branch "feature/test" after removing worktree',
expect.any(Error)
)
expect(warnSpy).toHaveBeenCalledWith(
'[git] Preserved local branch "feature/test" after removing worktree (not fully merged)',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('force-deletes a preserved branch only at its saved head', async () => {
mockGitCommands({})
await forceDeleteLocalBranch('/repo', 'feature/test', 'def456')
const calls = getGitCalls()
expect(calls).toContain('git worktree list --porcelain')
expect(calls).toContain('git update-ref -d refs/heads/feature/test def456')
expect(calls).toContain('git config --remove-section branch.feature/test')
})
it('refuses to force-delete a preserved branch that is checked out again', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
}
})
await expect(forceDeleteLocalBranch('/repo', 'feature/test', 'def456')).rejects.toThrow(
'checked out in another worktree'
)
expect(getGitCalls()).not.toContain('git update-ref -d refs/heads/feature/test def456')
})
it('restores a preserved branch when a concurrent checkout wins after deletion', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git worktree list --porcelain#2': {
stdout: `worktree /repo-feature
HEAD 0000000000000000000000000000000000000000
branch refs/heads/feature/test
`
}
})
await expect(forceDeleteLocalBranch('/repo', 'feature/test', 'def456')).rejects.toThrow(
'checked out in another worktree'
)
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
'update-ref',
'refs/heads/feature/test',
'def456',
''
])
})
it('refuses to force-delete a preserved branch after its head changes', async () => {
mockGitCommands({
'git update-ref -d refs/heads/feature/test def456': {
error: new Error('cannot lock ref')
}
})
await expect(forceDeleteLocalBranch('/repo', 'feature/test', 'def456')).rejects.toThrow(
'changed after the workspace was deleted'
)
expect(getGitCalls()).not.toContain('git config --remove-section branch.feature/test')
})
})
describe('assertWorktreeCleanForRemoval', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})
it('returns without checking git status for force removals', async () => {
await expect(assertWorktreeCleanForRemoval('/repo-feature', true)).resolves.toBeUndefined()
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('passes when git status output is empty', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(assertWorktreeCleanForRemoval('/repo-feature')).resolves.toBeUndefined()
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['status', '--porcelain', '--untracked-files=all'],
{ cwd: '/repo-feature', timeout: WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS }
)
})
it('throws a dedicated dirty/untracked error when status output is non-empty', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '?? scratch.txt\n', stderr: '' })
await expect(assertWorktreeCleanForRemoval('/repo-feature')).rejects.toMatchObject({
message: 'Worktree has uncommitted or untracked changes.',
stdout: '?? scratch.txt\n'
})
})
it('ignores configured linked paths without mutating them before preflight', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '?? node_modules\0', stderr: '' })
await expect(
assertWorktreeCleanForRemoval('/repo-feature', false, {
ignoredUntrackedPaths: ['node_modules']
})
).resolves.toBeUndefined()
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['status', '--porcelain', '-z', '--untracked-files=all'],
{ cwd: '/repo-feature', timeout: WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS }
)
})
it('allows a shorter caller deadline for destructive preflight', async () => {
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('git timed out.'))
await expect(
assertWorktreeCleanForRemoval('/repo-feature', false, { timeout: 25 })
).rejects.toThrow('git timed out.')
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['status', '--porcelain', '--untracked-files=all'],
{ cwd: '/repo-feature', timeout: 25 }
)
})
it('still rejects real changes when configured linked paths are ignored', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: '?? node_modules\0 M src/index.ts\0',
stderr: ''
})
await expect(
assertWorktreeCleanForRemoval('/repo-feature', false, {
ignoredUntrackedPaths: ['node_modules']
})
).rejects.toMatchObject({ message: 'Worktree has uncommitted or untracked changes.' })
})
it('rethrows preflight subprocess failures as-is', async () => {
const error = Object.assign(new Error('fatal: not a git repository'), {
stderr: 'fatal: not a git repository (or any of the parent directories): .git\n'
})
gitExecFileAsyncMock.mockRejectedValueOnce(error)
await expect(assertWorktreeCleanForRemoval('/repo-feature')).rejects.toBe(error)
})
})
describe('listWorktrees', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockReset()
translateWslOutputPathsMock.mockImplementation((output: string) => output)
statMock.mockReset()
// Default: no worktree has a sparse-checkout config file. Tests that need
// sparse detection override this.
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
readFileMock.mockReset()
mockSparseCheckoutEnabledConfig()
resolveGitDirMock.mockReset()
resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
})
it('translates parsed path fields from line-block porcelain output', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' +
'worktree /home/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\nsparse\n\n'
})
translateWslOutputPathsMock.mockImplementation((output: string) =>
output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
)
await expect(listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).resolves.toEqual([
{
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature',
head: 'def456',
branch: 'refs/heads/feature/test',
isBare: false,
isSparse: true,
isMainWorktree: false
}
])
// Why: the non-sparse main worktree gets an fs probe of its sparse config
// file; the linked worktree short-circuits on the parsed `sparse` token and
// does not. Only one git subprocess runs regardless of worktree count.
expect(getGitCalls()).toEqual(['git worktree list --porcelain -z'])
expect(statMock).toHaveBeenCalledTimes(1)
expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2)
})
it('passes the selected WSL distro when translating Windows-path worktree output', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'worktree /mnt/c/Users/me/repo\nHEAD abc123\nbranch refs/heads/main\nsparse\n\n' +
'worktree /mnt/c/Users/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\nsparse\n\n'
})
translateWslOutputPathsMock.mockImplementation((output: string) =>
output
.replace('/mnt/c/Users/me/repo-feature', 'C:\\Users\\me\\repo-feature')
.replace('/mnt/c/Users/me/repo', 'C:\\Users\\me\\repo')
)
await expect(listWorktrees('C:\\Users\\me\\repo', { wslDistro: 'Ubuntu' })).resolves.toEqual([
{
path: 'C:\\Users\\me\\repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isSparse: true,
isMainWorktree: true
},
{
path: 'C:\\Users\\me\\repo-feature',
head: 'def456',
branch: 'refs/heads/feature/test',
isBare: false,
isSparse: true,
isMainWorktree: false
}
])
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], {
cwd: 'C:\\Users\\me\\repo',
wslDistro: 'Ubuntu',
timeout: WORKTREE_LIST_TIMEOUT_MS
})
expect(translateWslOutputPathsMock).toHaveBeenCalledWith(
expect.any(String),
'C:\\Users\\me\\repo',
{ wslDistro: 'Ubuntu' }
)
})
it('returns no worktrees when the repo path is gone', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
gitExecFileAsyncMock.mockRejectedValueOnce(
Object.assign(new Error('spawn git ENOENT'), {
code: 'ENOENT'
})
)
statMock.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
await expect(listWorktrees('/workspace/deleted-repo')).resolves.toEqual([])
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], {
cwd: '/workspace/deleted-repo',
timeout: WORKTREE_LIST_TIMEOUT_MS
})
expect(statMock).toHaveBeenCalledWith('/workspace/deleted-repo')
expect(warnSpy).toHaveBeenCalledWith(
'[git/worktree] repo path missing; skipping worktree list: /workspace/deleted-repo'
)
warnSpy.mockRestore()
})
it('returns no worktrees when the path exists but is not a git repo', async () => {
const warnSpy = vi.spyOn(console, 'warn')
gitExecFileAsyncMock.mockRejectedValueOnce(
Object.assign(new Error('Command failed: git worktree list --porcelain'), {
code: 128,
stdout: '',
stderr: 'fatal: not a git repository (or any of the parent directories): .git\n'
})
)
await expect(listWorktrees('/private/tmp/orca-issue-1582-test/my-repo')).resolves.toEqual([])
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], {
cwd: '/private/tmp/orca-issue-1582-test/my-repo',
timeout: WORKTREE_LIST_TIMEOUT_MS
})
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.join(' ') === 'worktree list --porcelain -z') {
return {
stdout:
'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' +
'worktree /home/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n\n',
stderr: ''
}
}
throw new Error(`Unexpected git call: ${args.join(' ')}`)
})
translateWslOutputPathsMock.mockImplementation((output: string) =>
output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
)
const featureWorktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature'
resolveGitDirMock.mockImplementation(async (worktreePath: string) =>
worktreePath === featureWorktreePath
? `${featureWorktreePath}\\.git-worktrees\\feature`
: `${worktreePath}/.git`
)
statMock.mockImplementation(async (filePath: string) => {
if (filePath.includes('repo-feature') && filePath.includes('sparse-checkout')) {
return { isFile: () => true, size: 32 }
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
const worktrees = await listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')
expect(worktrees).toEqual([
{
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature',
head: 'def456',
branch: 'refs/heads/feature/test',
isBare: false,
isSparse: true,
isMainWorktree: false
}
])
expect(resolveGitDirMock).toHaveBeenCalledWith(featureWorktreePath)
// Why: the detection path must not spawn a git subprocess per worktree —
// the perf regression in #1131 came from `git sparse-checkout list` firing
// on every poll.
expect(getGitCalls()).toEqual(['git worktree list --porcelain -z'])
})
it('bounds concurrent sparse-checkout filesystem probes', async () => {
const worktreeCount = 20
const sparseWorktreePath = '/repo-worktree-17'
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: Array.from({ length: worktreeCount }, (_, index) =>
[
`worktree ${index === 0 ? '/repo' : `/repo-worktree-${index}`}`,
`HEAD ${String(index).padStart(6, '0')}`,
`branch refs/heads/${index === 0 ? 'main' : `feature/${index}`}`,
''
].join('\n')
).join('\n'),
stderr: ''
})
const pendingProbeResolves: (() => void)[] = []
let activeProbes = 0
let maxActiveProbes = 0
statMock.mockImplementation(async (filePath: string) => {
activeProbes += 1
maxActiveProbes = Math.max(maxActiveProbes, activeProbes)
await new Promise<void>((resolve) => pendingProbeResolves.push(resolve))
activeProbes -= 1
if (filePath.replaceAll('\\', '/').includes(sparseWorktreePath)) {
return { isFile: () => true, size: 32 }
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
let completed = false
const listPromise = listWorktrees('/repo').finally(() => {
completed = true
})
for (let attempt = 0; pendingProbeResolves.length < 8 && attempt < 50; attempt += 1) {
await Promise.resolve()
}
expect(pendingProbeResolves).toHaveLength(8)
// Why: each probe may chain extra microtasks after stat (e.g. core.sparseCheckout
// config reads). Drain until the list settles, not a fixed microtask budget.
for (let attempt = 0; !completed && attempt < 100; attempt += 1) {
pendingProbeResolves.splice(0).forEach((resolve) => resolve())
await Promise.resolve()
await Promise.resolve()
}
expect(completed).toBe(true)
const worktrees = await listPromise
expect(maxActiveProbes).toBeLessThanOrEqual(8)
expect(statMock).toHaveBeenCalledTimes(worktreeCount)
expect(worktrees).toHaveLength(worktreeCount)
expect(worktrees[17]).toMatchObject({
path: sparseWorktreePath,
isSparse: true
})
})
it('falls back to line-block porcelain output when Git rejects -z', async () => {
mockGitCommands({
'git worktree list --porcelain -z': {
error: Object.assign(new Error("unknown switch `z'"), {
stderr: "error: unknown switch `z'"
})
},
'git worktree list --porcelain': {
stdout:
'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\n' +
'worktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n'
}
})
// Why: the fallback probes each linked worktree path for existence; keep
// the paths "present" so this test stays about parser selection.
statMock.mockImplementation(async (targetPath: string) => {
if (String(targetPath).endsWith('sparse-checkout')) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
return {}
})
await expect(listWorktrees('/repo')).resolves.toEqual([
{
path: '/repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/repo-feature',
head: 'def456',
branch: 'refs/heads/feature/test',
isBare: false,
isMainWorktree: false
}
])
expect(getGitCalls()).toEqual([
'git worktree list --porcelain -z',
'git worktree list --porcelain'
])
})
it('annotates missing linked worktrees as prunable via the line-block fallback', async () => {
// Why: Git <2.36 lacks the `prunable` porcelain field (issue #8389), so
// the fallback must probe each linked worktree path instead of treating a
// stale registration as a live workspace.
mockGitCommands({
'git worktree list --porcelain -z': {
error: Object.assign(new Error("unknown switch `z'"), {
stderr: "error: unknown switch `z'"
})
},
'git worktree list --porcelain': {
stdout:
'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\n' +
'worktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n\n' +
'worktree /repo-locked\nHEAD aaa789\nbranch refs/heads/agent\nlocked agent session\n'
}
})
// statMock default (beforeEach): every path is missing (ENOENT).
const worktrees = await listWorktrees('/repo')
expect(worktrees.find((worktree) => worktree.path === '/repo-feature')).toMatchObject({
prunable: true
})
// Locked registrations are shielded, mirroring git's own prunable rules;
// the main worktree is covered by the repo-level missing-path handling.
expect(worktrees.find((worktree) => worktree.path === '/repo-locked')?.prunable).toBeUndefined()
expect(worktrees.find((worktree) => worktree.path === '/repo')?.prunable).toBeUndefined()
})
})
describe('addSparseWorktree', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockReset()
translateWslOutputPathsMock.mockImplementation((output: string) => output)
statMock.mockReset()
// Default: no worktree has a sparse-checkout config file. Tests that need
// sparse detection override this.
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
resolveGitDirMock.mockReset()
resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
})
it('separates sparse checkout directory operands from options', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['-docs', 'src'])
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['sparse-checkout', 'set', '--', '-docs', 'src'],
{ cwd: '/repo-feature' }
)
})
it('removes the worktree and deletes the created branch when sparse setup fails', async () => {
mockGitCommands({
// Why: addWorktree probes push.autoSetupRemote after `worktree add` to
// decide whether to set it locally. Without an explicit mock the helper
// returns empty stdout and the production code skips the `--local` write,
// exercising the wrong branch. Throw with code 1 to mirror git's "key
// unset" exit, which is what worktree.ts treats as "needs to be set".
'git config --get push.autoSetupRemote': {
error: Object.assign(new Error('key unset'), { code: 1 })
},
'git sparse-checkout set -- packages/web': {
error: new Error('sparse setup failed')
},
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
}
})
await expect(
addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web'])
).rejects.toThrow('sparse setup failed')
const calls = getGitCalls()
expect(calls).toEqual(
expect.arrayContaining([
'git worktree add --no-checkout --no-track -b feature/test /repo-feature',
'git config --get push.autoSetupRemote',
'git config --local push.autoSetupRemote true',
'git sparse-checkout init --cone',
'git sparse-checkout set -- packages/web',
'git config --local --unset-all branch.feature/test.base',
'git worktree remove --force /repo-feature',
'git branch -D -- feature/test'
])
)
expect(calls).not.toContain('git worktree prune')
expectGitCallOrder(
calls,
'git sparse-checkout set -- packages/web',
'git worktree remove --force /repo-feature'
)
expectGitCallOrder(
calls,
'git worktree remove --force /repo-feature',
'git branch -D -- feature/test'
)
})
})