Files
Maximilian Roos e91b5fa885 perf(prune): parallelize removals on the scan lock's read side (#3631)
`wt step prune` executed removals one at a time under the write side of
`check_lock` while the candidate scan fanned out on the read side. On
the rust-scale fixture that serialized ~2.6 s of a ~2.9 s live prune.
Removals now run concurrently: the main thread streams scan results into
a FIFO queue of per-candidate jobs, and a worker pool sized like the
rayon pool executes them under the read side of the lock.

**Why the read side is safe.** The lock exists for the Windows
`.git/config` race (#2801): `git branch -D` rewrites config via lockfile
plus atomic rename, and a concurrent reader fails on the rename. The
removal chain has since moved to the CAS `git update-ref -d`, and
(verified empirically, by inode-watching `.git/config` across each
command) neither it nor `git worktree remove` / `git worktree prune`
touches config; `branch -D` remains reachable only through arms prune
never takes. Concurrent `git worktree prune` runs are idempotent and
exit 0, and `update-ref -d` does not consult worktree checkout state, so
nothing in the hook-free chain needs exclusivity. The write side remains
for the removals that do: hook-bearing candidates (a foreground
`pre-remove` body is an arbitrary command with streamed output),
`--foreground` candidates (TTY spinner), the fail-hard metadata-pruning
candidates (`StaleDetached`, plan-less `Prunable`), and the deferred
current worktree. `removal_needs_write` in `src/commands/step/prune.rs`
is the decision table; the `check_lock` field doc carries the full
analysis.

**Output and determinism.** Skip lines ride the same FIFO queue as
removals, so `RAYON_NUM_THREADS=1` (one worker) reproduces the exact
serial output order the snapshot tests pin; with more workers, whole
lines interleave (one `anstream` `eprintln!` writes a line under a
single stderr lock). Live `--format=json` output is now sorted by scan
index, matching dry-run, with the current worktree still last. The first
failing removal flips an abort flag that drains the rest of the queue
unexecuted, matching the serial loop's abort-on-first-error.

**Measured.** `prune_e2e/live` criterion: ~615 ms to ~398 ms. Rust-scale
live one-shot (36 worktrees, 24 candidates, warm): 2.9 s to 0.56 s wall,
all 24 removals overlapping in the trace (6.5 s of subprocess time).

**Abort semantics.** The first failing removal flips an abort flag:
in-flight removals complete (their per-candidate lines already printed),
queued ones drain unexecuted, no summary prints, and the first error
propagates — the same abort-on-first-error the serial loop had, where an
error mid-loop likewise left earlier removals done and later candidates
untouched. A mid-scan check error takes the same abort path.

**Testing.** New `test_prune_removals_run_concurrently` proves the
overlap causally: two orphan-branch removals barrier on each other
inside a git shim, and serialized removals trip the shim's timeout
sentinel (verified: the test fails under forced `RAYON_NUM_THREADS=1`).
`test_prune_removal_failure_aborts_remaining_queue` and
`test_prune_concurrent_removal_failures_report_first` pin the abort
semantics deterministically (single-worker FIFO, and a barrier forcing
two concurrent failures). The config-race canary keeps its role with its
premise rewritten: a regression to a config-rewriting deletion is what
it would now catch. The deterministic-output snapshot tests pass
unchanged. The one patch line no test reaches deterministically is the
worker's early `return` when the result receiver is gone (a scan error
racing an in-flight removal).

Accepted residuals, documented in code: a `packed-refs.lock` contended
past git's ~1 s retry budget reads as `RetainedRaced` (fail-closed;
unobserved at 24-way concurrency on local disks), and concurrent
fail-soft `git worktree prune` races leave at worst stale metadata for
the next prune to collect.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:49:59 -07:00
..