Files
orca/src/main/git/worktree-scan-cache-sharing.test.ts
T
Brennan Benson 6f2a7d05c9 fix(worktrees): let git delete removed checkouts so chat sends never wait behind them (#23837)
* fix(worktrees): delete removed checkouts in git, not in Orca's file pool

Local worktree removal renamed the checkout into a sibling trash root and
deleted it in the background with a recursive fs.rm in the main process.
That queued one request per entry on libuv's shared 4-thread file pool, so
for minutes every other async fs call in the main process (the agent-session
store behind chat sends, file explorer reads) waited behind the delete.

`git worktree remove` now deletes the checkout inline in git's own process
again, so the card stays in its Deleting state for the length of the delete
while Orca's file pool stays free. No timeout applies to the call, so a
large delete is never killed halfway.

If git reports success but the path still exists (Git for Windows leaves
junctions and their parent directories in place), the leftover is deleted
with the existing removeHostTree; WSL checkouts stay with the distro.

Nothing creates trash any more: the scheduling queue, rename/restore
helpers and the trash_rename span are gone. The startup sweep stays to
drain entries older releases left behind, and now removes each emptied
trash root so the obligation ends.

* fix(worktrees): let Git delete Windows checkouts with long paths enabled

Removal now always runs Git's own recursive delete, and worktree creation
checks out with core.longpaths on Windows, so a deep checkout Orca created
could fail to delete with "Filename too long" (#6433). The Windows recovery
then finishes the delete but keeps the branch. Pass the same command-scoped
core.longpaths option to `git worktree remove` so Git can delete what it
created.

Also point the CI shard timing entry at the renamed real-git removal suite.

* fix(worktrees): keep an inherited GIT_ASK_YESNO out of the worktree delete

Git for Windows asks $GIT_ASK_YESNO whether to retry when a file stays
locked during a recursive delete. Orca's git env inherits the user's
environment, so an inherited value would run an arbitrary prompt program
in the middle of a removal. Drop it for the removal call only.

* perf(worktrees): run worktree deletes under their own limit, outside git admission

`git worktree remove` now deletes the whole checkout in Git's own process,
which takes 20-35 s on a large tree. It took a general git admission slot at
status tier for that whole time, and that cap is as small as two slots on a
machine with six or fewer cores, so two deletes blocked every status read.

Deletes now skip general admission and queue under their own limit of two
per host instead: two concurrent deletes already saturate one disk, and more
only slow each other down. Leftover cleanup runs inside the same slot.

* fix(worktrees): delete removed checkouts in the background and mark them removing

Since the checkout is deleted by `git worktree remove` in Git's own process,
a large delete takes 20-35 s. Answering the request only after that made web
and mobile (30 s), paired desktop (60/180 s) and the CLI (60 s) report a
failure for a delete that was still going, and mobile silently re-showed the
row.

The request now does everything that can refuse (lock, cleanliness, archive
hook, watcher/terminal gate, terminal stop, shared-link unlink), records the
removal in an in-memory table on the host and answers `removing: true`. The
delete, branch cleanup and metadata purge run after it in the same order as
before, and the watcher/terminal gate stays held until they finish.

- Listings mark rows in the table `removing` for clients that advertise
  `worktree.background-removal.v1` (the desktop renderer, paired desktop and
  web), and leave them out for everyone else (older clients, mobile, the
  CLI), which already dropped the row when the request answered.
- The outcome (removed, with any preserved branch, or the error) rides the
  existing worktrees-changed event as an optional field, sent after the row
  has left the table.
- A repeat delete while Git runs joins it. A create at the same path or with
  the same branch is refused with "Cleanup is pending; try again shortly";
  create's name search skips the path, so generated names move on.
- Nothing is persisted: after a quit or crash Git still lists the checkout
  and it can be deleted again. WSL checkouts still delete inline.
- `orca worktree rm` says the checkout is still being deleted.

* fix(worktrees): keep the existing Deleting card until the host's Git finishes

The host now answers a local worktree delete on acceptance and deletes in the
background. The renderer keeps the existing delete state set until the host
publishes how it ended:

- The delete that asked waits for the outcome on the worktrees-changed event
  (local IPC or the paired runtime's client event), then runs the same
  teardown, preserved-branch toast and card error an inline delete did. If
  that event is lost to a dropped connection, a listing that shows the row
  gone after it was marked removing finishes the wait, and one that shows it
  back without the marker fails it.
- Any other renderer (a reload, a paired desktop, web) sets the same delete
  state from the host's `removing` marker and clears it when the marker goes.
  A failure the host publishes lands on that card's existing error.
- Web advertises `worktree.background-removal.v1` so the host sends it the
  marker; paired desktop does through the Electron capability list.

No new component, style or state: the card reads the delete state it always
did. A host that predates this answers when done without `removing`, and the
renderer takes that as finished, as before.

* test(worktrees): type the removal harness and projection for the node typecheck

* fix(worktrees): don't fail a delete retry with an earlier attempt's buffered failure

A background removal's outcome that reached this renderer with no waiter (another client's
delete, a host-marked card, or one already settled from listings) was buffered for 60 s and
consumed by the next delete of the same workspace, so retrying a failed delete failed at once
with the old error while the host was deleting. Drop the buffered outcome before sending the
request; only an outcome that arrives after it can belong to it.

* fix(worktrees): let only a gap in host events settle a background delete from listings

Git unlists the checkout before the host deletes the branch, cleans the push target and purges
metadata, and the worktree-directory watcher refetches within 250 ms. The renderer read the
missing row as a finished delete, so the waiter resolved without the preserved branch (no
toast) and a failure in those last steps showed as success; the real outcome was then dropped.
The listing fallback exists only for a lost outcome event, so it now applies only after this
host's event stream had a gap: a new subscription or a replay after reconnect.

* perf(worktrees): let a bulk delete start each same-repo checkout delete once the host accepts the last

A bulk delete ran one worktree at a time per repo (#2259, for packed-refs and ref-lock races in
branch cleanup). With Git now deleting each checkout for 20-35 s before the request settles, N
worktrees in one repo took N times that. The renderer now queues same-repo deletes only until
the host accepts each one; a parent still waits for its nested children to finish. The host
serializes the branch cleanup step per repo itself, which also covers removals started by
different clients.

* test(worktrees): pin the host platform in the mocked removal suites so they pass on Windows

Removal now passes -c core.longpaths=true on Windows, so the exact-argv
assertions and command-keyed mocks never matched there (17 failures on a
Windows host). Pin darwin as the add-worktree suites already do, and drive
the one Windows-specific case through the same spy.

* test(worktrees): type the blocked git remove result instead of a broad object

The anti-slop static-analysis gate rejects `object` parameters.

* test(worktrees): clear the changed-code quality gate in the removal suites

Merge the duplicate node:fs import, build the mock child without a cast, read
worktrees:list rows through one typed helper, and give the remaining casts a SAFETY line.

* fix(worktrees): record each background delete durably and finish it after a quit or crash

A quit mid-delete left git to finish the checkout on its own while the branch
delete and metadata purge never ran; a crash left a normal-looking row. Each
accepted local removal now writes a record beside the profile state before git
starts, clears it on success or failure, and the host runs the same delete
again for any record left at startup, re-deriving what remains from git and
disk. An orderly quit stops the checkout delete without waiting for it.

* test(worktrees): type the interrupted-removal assertions for the node typecheck

* fix(worktrees): finish an interrupted delete that already removed the checkout's .git file

Quit stops git worktree remove mid-delete, and Git deletes the checkout's .git
file wherever it falls in directory order. Git then refuses the checkout
("validation failed ... .git does not exist") on every retry, so the startup
finish failed and the row could never be deleted from Orca. A registered
checkout this record owns that has lost its .git file now finishes like an
unregistered one: leftover files, prune, then the branch.

* fix(worktrees): let Git finish an interrupted delete, and never take a different checkout

A quit or crash that stops `git worktree remove` after it deleted the checkout's
.git file left a registered checkout Git refuses to remove. The previous fix
deleted that leftover inside Orca's process, which is the bulk delete this
change exists to avoid (and on Windows the leftover can be most of the
checkout). The startup finish now rewrites the missing .git file from Git's
own admin entry for that path and lets `git worktree remove --force` delete
it. `git worktree repair` is not used: it also re-points every other
registered path, including a checkout another repository now owns there.
Orca deletes the leftover itself only when no admin entry claims the path.

The startup finish forces, so it now leaves the path alone when the checkout
there is not the one recorded: a registered worktree on a different branch or
head, or a `.git` at a path Git already unregistered. The record is dropped and
the card shows why.

The record write before Git starts is now bounded (2 s, logged when exceeded)
so a stalled disk cannot hold the delete, and the outcome is published before
the record's clear reaches disk.

* test(worktrees): compare worktree paths by value and tear down with Windows lock retries

Git prints forward slashes in `git worktree list` on Windows, so the real-Git
removal suites never found a joined path there: positive checks failed and
negative ones passed without proving anything. They now compare Git's parsed
rows by value. Teardown uses the shared retrying removeTree, since Windows can
hold the deleted checkout busy for a moment after Git exits. Adds a
relative-path worktree case for the .git restore (skipped before Git 2.48).

* fix(worktrees): reply to a worktree delete when it has finished, not on a broadcast event

A current client's delete request now waits for the host's background delete and gets its real
result (removed, a preserved branch, or the error) as the reply, the way it did before the delete
moved off the request. A request that arrives while the delete runs joins it and gets the same
result. Every other view keeps reading the host's `removing` marker: the row leaving means the
delete finished, and the row listed again without the marker shows "The delete did not finish.
Try again." on a card that view had marked Deleting. A request whose reply is lost (a timeout or a
dropped connection) settles the same way from a fresh listing instead of reporting a failure.

Clients without the background-removal capability (mobile, the CLI, older desktops) are still
answered on acceptance and have rows under removal left out of their listings.

This removes the outcome on worktreesChanged and everything it needed: the renderer's outcome
waiters, early-outcome buffer and TTL, per-host event-gap generations, the request pre-registration,
and the accept callback bulk delete used. Bulk delete runs same-repo deletes in parallel only on
this machine, whose host serializes branch cleanup per repo; SSH and paired hosts stay serialized.

* test(worktrees): type the pending-removal host id in the background-removal suite

* fix(worktrees): answer a delete request even when a concurrent removal of the same worktree replaced its record

The desktop app's removal and the runtime removal (CLI, paired clients) coalesce separately, so
both can be accepted for one worktree. The second replaced the first's record, and the first
delete then finished without resolving the request waiting on it, leaving the desktop card on
Deleting indefinitely. Each delete now settles the request it was started for.

* fix(worktrees): run same-repo removal archive hooks and teardown one at a time on the host

Local bulk delete now sends same-repo removals in parallel, so their archive hooks, terminal
teardown and preflight ran at once; a hook that writes refs can race the repo's ref locks
(#2259). The host now serializes each local removal up to acceptance per repo, for every
client; Git's checkout delete still runs in parallel under the delete limit.

* fix(runtime): keep waiting worktree deletes out of a host's foreground call slots

worktree.rm now replies only after Git deletes the checkout (up to minutes), so on paired
desktop and web each waiting delete held one of the host's 8 foreground call slots, and a
bulk delete queued listing refreshes and every other foreground call behind it. Deletes now
run in their own lane with the same bound; the 2-slot background lane stays for status polls.

* fix(worktrees): join a same-worktree delete accepted while a removal waited its repo turn

The desktop app and the runtime (CLI, paired clients, web) check for a running delete before
they queue for the repo's acceptance turn. A delete of the same worktree from the other path,
accepted while this one queued, was missed: this request re-ran the archive hook, stopped the
terminals again and started a second `git worktree remove` on the directory Git was deleting.
The queued acceptance now re-checks and joins the running delete.

* fix(worktrees): fence a resumed delete's checkout from startup, and drop rows a listing read before the delete finished

A delete a quit or crash interrupted took its terminal and file-watcher gate only when the resume
job ran, after the first window was shown; session restore could open a shell or watcher inside the
half-deleted checkout first, and on Windows that handle can fail the resumed git delete. Loading the
records now fences each recorded path, and the resumed job takes the fence over in the same tick it
takes its own gate.

A listing that read git's registration before a delete finished, and replied after the removal
record cleared, returned the row unmarked, so other views briefly showed "The delete did not
finish". Listings now capture the pending removals before reading git and leave out a row whose
delete finished successfully since; a row whose delete failed stays listed as before.

* test(worktrees): keep git's auto-maintenance out of the real-git removal suite

CI's Git 2.55 failed the file-pool test in teardown with ENOTEMPTY on the scratch repo's
objects/pack after the test body passed: the 3,000-file commit's detached auto-maintenance was
still writing a pack. The scratch repo now disables auto-maintenance and auto-gc.

* fix(worktrees): one archive-hook approval covers a same-repo bulk delete again

Local same-repo deletes now start together, so each queued its trust prompt with a state snapshot
taken before the first prompt was answered; approving the first still showed the same prompt once
per remaining worktree. The queued check now reads the store when its turn comes.
2026-09-30 16:32:20 -07:00

497 lines
18 KiB
TypeScript

import { worktreeCreateGit } from './worktree-create-git-executor'
// Worktree scan sharing: in-flight coalescing and mutation-generation retirement.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn(),
translateWslOutputPathsMock: vi.fn((output: string) => output)
})
)
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
import {
_getWorktreeScanCacheSizesForTests,
_resetWorktreeScanCacheForTests,
listWorktreeGraph,
listWorktrees,
listWorktreesSharedStrict,
listWorktreesStrict,
moveWorktree,
removeWorktree,
notifyPreparedWorktreeMutation,
WORKTREE_LIST_TIMEOUT_MS
} from './worktree'
import { registerWorktreeSuiteHooks } from './worktree-test-harness'
registerWorktreeSuiteHooks()
describe('listWorktrees in-flight sharing', () => {
beforeEach(async () => {
gitExecFileAsyncMock.mockReset()
_resetWorktreeScanCacheForTests()
// Why: capability discovery serializes same-host callers; warming it lets
// these tests isolate the scan-generation overlap they are exercising.
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await listWorktrees('/capability-warmup')
gitExecFileAsyncMock.mockReset()
})
// Later describes in this file assume a pristine mock (no global reset).
afterEach(() => {
gitExecFileAsyncMock.mockReset()
})
it('shares one scan across concurrent calls for the same repo', async () => {
let resolveScan!: () => void
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = () => resolve({ stdout: scanOutput })
})
)
const first = listWorktrees('/repo')
const second = listWorktrees('/repo')
resolveScan()
const [a, b] = await Promise.all([first, second])
expect(a).toEqual(b)
expect(a[0]?.path).toBe('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('shares one graph scan across concurrent callers without sparse probes', async () => {
let resolveScan!: () => void
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = () => resolve({ stdout: scanOutput })
})
)
const first = listWorktreeGraph('/repo')
const second = listWorktreeGraph('/repo')
resolveScan()
const [a, b] = await Promise.all([first, second])
expect(a).toEqual(b)
expect(a[0]?.path).toBe('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
// The annotated scan is the graph scan plus a sparse probe, so the two share one `git worktree
// list` and only the annotated caller pays the probe. They ran Git twice before.
it('runs one git listing for concurrent graph and annotated scans', async () => {
const resolvers: ((value: { stdout: string }) => void)[] = []
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve)
})
)
const graphScan = listWorktreeGraph('/repo')
const annotatedScan = listWorktrees('/repo')
expect(resolvers).toHaveLength(1)
for (const resolve of resolvers) {
resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' })
}
await Promise.all([graphScan, annotatedScan])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
// The create's listing is promoted to `interactive` precisely to skip the queue a status scan
// is already sitting in; joining that scan would hand it the wait back.
it('does not let an interactive listing join a scan queued at another tier', async () => {
const resolvers: ((value: { stdout: string }) => void)[] = []
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve)
})
)
const statusScan = listWorktreeGraph('/repo', { admissionTier: 'status' })
const interactiveScan = worktreeCreateGit.run(() => listWorktreeGraph('/repo'))
expect(resolvers).toHaveLength(2)
for (const resolve of resolvers) {
resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' })
}
await Promise.all([statusScan, interactiveScan])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
// Order must not matter: whichever runs first owns the listing and the other joins it.
it('runs one git listing when the annotated scan starts first', async () => {
const resolvers: ((value: { stdout: string }) => void)[] = []
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve)
})
)
const annotatedScan = listWorktrees('/repo')
const graphScan = listWorktreeGraph('/repo')
expect(resolvers).toHaveLength(1)
for (const resolve of resolvers) {
resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' })
}
await Promise.all([annotatedScan, graphScan])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('keeps graph scans with an AbortSignal isolated from shared callers', async () => {
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
const controller = new AbortController()
await Promise.all([
listWorktreeGraph('/repo'),
listWorktreeGraph('/repo', { signal: controller.signal })
])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('keeps graph scans with different visibility or timeout contracts separate', async () => {
const resolvers: ((value: { stdout: string }) => void)[] = []
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve)
})
)
const defaultScan = listWorktreeGraph('/repo')
const preparationScan = listWorktreeGraph('/repo', { includeCreatePreparations: true })
const shorterScan = listWorktreeGraph('/repo', { timeout: 5_000 })
expect(resolvers).toHaveLength(3)
for (const resolve of resolvers) {
resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' })
}
await Promise.all([defaultScan, preparationScan, shorterScan])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)
})
it('retires a graph scan that overlaps a worktree mutation', async () => {
const resolvers: ((value: { stdout: string }) => void)[] = []
gitExecFileAsyncMock.mockImplementation((args: string[]) =>
args[0] === 'worktree' && args[1] === 'list'
? new Promise((resolve) => {
resolvers.push(resolve)
})
: Promise.resolve({ stdout: '' })
)
const staleScan = listWorktreeGraph('/repo')
expect(resolvers).toHaveLength(1)
notifyPreparedWorktreeMutation('/repo')
const freshScan = listWorktreeGraph('/repo')
expect(resolvers).toHaveLength(2)
resolvers[1]?.({ stdout: 'worktree /repo\nHEAD fresh\nbranch refs/heads/main\n' })
resolvers[0]?.({ stdout: 'worktree /repo\nHEAD stale\nbranch refs/heads/main\n' })
expect((await freshScan)[0]?.head).toBe('fresh')
expect((await staleScan)[0]?.head).toBe('stale')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
})
// Why (#16520): create verification moved off the fail-soft listing so a Git failure stops being
// hidden behind "created but not found in listing". That must not cost the in-flight coalescing,
// and sharing must never hand a strict caller a softened result.
it('coalesces concurrent strict scans for the same repo into one git call', async () => {
let resolveScan!: () => void
const scanOutput = 'worktree /repo\0HEAD abc123\0branch refs/heads/main\0\0'
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = () => resolve({ stdout: scanOutput })
})
)
const first = listWorktreesSharedStrict('/repo')
const second = listWorktreesSharedStrict('/repo')
resolveScan()
const [a, b] = await Promise.all([first, second])
expect(a).toEqual(b)
expect(a[0]?.path).toBe('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('propagates a shared strict failure to every joiner', async () => {
let rejectScan!: (error: Error) => void
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((_resolve, reject) => {
rejectScan = (error: Error) => reject(error)
})
)
const first = listWorktreesSharedStrict('/repo')
const second = listWorktreesSharedStrict('/repo')
const settled = Promise.allSettled([first, second])
rejectScan(new Error('git timed out.'))
// A joiner silently receiving [] would be #16520 re-entering through the dedup path.
expect((await settled).map((r) => r.status)).toEqual(['rejected', 'rejected'])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('never lets a strict caller join a fail-soft scan', async () => {
// Both variants reject; only the lenient one is allowed to soften that into [].
gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.'))
const lenient = listWorktrees('/repo')
const strict = listWorktreesSharedStrict('/repo')
await expect(lenient).resolves.toEqual([])
await expect(strict).rejects.toThrow('git timed out.')
})
it('keeps listWorktreesStrict unshared for post-mutation verification', async () => {
// Why: a raw `git worktree prune` never bumps the scan generation, so a
// coalesced strict read could return the pre-prune row and report a
// successful removal as a stale registration.
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
const resolvers: (() => void)[] = []
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(() => resolve({ stdout: scanOutput }))
})
)
const first = listWorktreesStrict('/repo')
const second = listWorktreesStrict('/repo')
for (const resolve of resolvers) {
resolve()
}
await Promise.all([first, second])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not share scans across different timeout contracts', async () => {
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
await Promise.all([listWorktrees('/repo'), listWorktrees('/repo', { timeout: 5_000 })])
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['worktree', 'list', '--porcelain', '-z'],
{ cwd: '/repo', timeout: WORKTREE_LIST_TIMEOUT_MS }
],
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo', timeout: 5_000 }]
])
})
it('runs a fresh scan once the shared one has settled', async () => {
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
await listWorktrees('/repo')
await listWorktrees('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('runs a fresh scan after a timed-out shared scan settles', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('git timed out.'))
.mockResolvedValueOnce({
stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
})
await expect(listWorktrees('/repo')).resolves.toEqual([])
await expect(listWorktrees('/repo')).resolves.toEqual([
expect.objectContaining({ path: '/repo', head: 'abc123' })
])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
} finally {
warnSpy.mockRestore()
}
})
it('distinguishes fail-soft empty results from strict scan failures', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const failure = new Error('git spawn timed out')
gitExecFileAsyncMock.mockRejectedValue(failure)
await expect(listWorktrees('/repo')).resolves.toEqual([])
await expect(listWorktreesStrict('/repo')).rejects.toBe(failure)
} finally {
warnSpy.mockRestore()
}
})
it('does not share scans across different repos', async () => {
gitExecFileAsyncMock.mockImplementation((_args: string[], options: { cwd: string }) =>
Promise.resolve({
stdout: `worktree ${options.cwd}\nHEAD abc123\nbranch refs/heads/main\n`
})
)
await Promise.all([listWorktrees('/repo-one'), listWorktrees('/repo-two')])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not retain scan generations after mutations without active scans', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await Promise.all(
Array.from({ length: 128 }, (_, index) =>
moveWorktree(`/repo-${index}`, `/repo-${index}-old`, `/repo-${index}-new`)
)
)
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
})
it('cleans mutation generations after stale scans settle and supports repo reuse', async () => {
const scanResolvers: ((stdout: string) => void)[] = []
let listCalls = 0
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'worktree' && args[1] === 'list') {
listCalls += 1
return new Promise((resolve) => {
scanResolvers.push((stdout) => resolve({ stdout }))
})
}
return Promise.resolve({ stdout: '' })
})
const staleScan = listWorktrees('/repo')
expect(scanResolvers).toHaveLength(1)
await moveWorktree('/repo', '/repo-old', '/repo-new')
// Two entries per annotated scan: its own, plus the graph listing it shares with probe-free callers.
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
const freshScan = listWorktrees('/repo')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 4, generations: 1 })
scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n')
expect((await freshScan)[0]?.path).toBe('/repo-new')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n')
expect((await staleScan)[0]?.path).toBe('/repo')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
const firstReuse = listWorktrees('/repo')
const secondReuse = listWorktrees('/repo')
expect(listCalls).toBe(3)
scanResolvers[2]?.('worktree /repo-reused\nHEAD reused\nbranch refs/heads/main\n')
const [firstResult, secondResult] = await Promise.all([firstReuse, secondReuse])
expect(firstResult).toEqual(secondResult)
expect(firstResult[0]?.path).toBe('/repo-reused')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
})
it('retires every overlapping scan generation across repeated mutations', async () => {
const scanResolvers: (() => void)[] = []
let listCalls = 0
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'worktree' && args[1] === 'list') {
listCalls += 1
return new Promise((resolve) => {
scanResolvers.push(() => resolve({ stdout: '' }))
})
}
return Promise.resolve({ stdout: '' })
})
const oldestScan = listWorktrees('/repo')
await moveWorktree('/repo', '/old-0', '/new-0')
const middleScan = listWorktrees('/repo')
await moveWorktree('/repo', '/old-1', '/new-1')
const newestScan = listWorktrees('/repo')
expect(listCalls).toBe(3)
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 6, generations: 1 })
scanResolvers[0]?.()
scanResolvers[2]?.()
scanResolvers[1]?.()
await Promise.all([oldestScan, middleScan, newestScan])
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
})
it('keeps WSL scans distinct while retiring both after a mutation', async () => {
const scanResolvers: (() => void)[] = []
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await Promise.all([
listWorktrees('/capability-warmup', { wslDistro: 'Ubuntu' }),
listWorktrees('/capability-warmup', { wslDistro: 'Debian' })
])
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'worktree' && args[1] === 'list') {
return new Promise((resolve) => {
scanResolvers.push(() => resolve({ stdout: '' }))
})
}
return Promise.resolve({ stdout: '' })
})
const staleUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' })
const staleDebian = listWorktrees('/repo', { wslDistro: 'Debian' })
expect(scanResolvers).toHaveLength(2)
await removeWorktree('/repo', '/worktree', false, {
knownRemovedWorktree: { branch: '', head: '' },
wslDistro: 'Ubuntu'
})
const freshUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' })
const freshDebian = listWorktrees('/repo', { wslDistro: 'Debian' })
expect(scanResolvers).toHaveLength(4)
for (const resolve of scanResolvers) {
resolve()
}
await Promise.all([staleUbuntu, staleDebian, freshUbuntu, freshDebian])
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
})
it('does not share scans for callers with an AbortSignal', async () => {
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
const controller = new AbortController()
// Why: an aborted shared scan would reject for every caller, so signal
// callers must keep a private scan.
await Promise.all([
listWorktrees('/repo'),
listWorktrees('/repo', { signal: controller.signal })
])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
})