Skip to content

perf(remove, prune): one check per guarantee in the removal chain - #3617

Merged
max-sixty merged 2 commits into
mainfrom
prune-perf
Jul 27, 2026
Merged

perf(remove, prune): one check per guarantee in the removal chain#3617
max-sixty merged 2 commits into
mainfrom
prune-perf

Conversation

@max-sixty

@max-sixty max-sixty commented Jul 26, 2026

Copy link
Copy Markdown
Owner

wt step prune scans in parallel, but each removal ran a serial chain of ~17 git subprocesses under the scan write lock: a re-run of prepare_worktree_removal (the scan had already run it and kept only is_ok()), a post-hook "safety refresh" (status + fresh snapshot + integration re-compute) that ran even when no pre-remove hooks executed, and a final pre-rename clean check that ran right after the fsmonitor daemon stop, paying a full un-fsmonitored re-stat. At rust-lang/rust scale that post-stop status is the 0.5-1.7 s per-removal driver.

This trims the chain to one mechanism per guarantee, with no behavioral additions:

  • Prune passes the scan-time plan through (CheckOutcome.plan) instead of re-preparing under the write lock. Prunable candidates still prepare in try_remove because preparing them prunes stale metadata, which must stay serialized.
  • refresh_removal_safety_after_pre_remove is deleted. prepare_worktree_removal now computes the integration verdict and effective target for display (carried on RemoveResult::RemovedWorktree), the pre-rename clean check catches hook-dirtied worktrees, and delete_branch_if_safe's CAS re-decides the branch deletion against fresh refs. The clean check also moves into remove_worktree_with_cleanup, so the foreground and picker paths get their final gate adjacent to the rename rather than relying on the deleted refresh.
  • The clean check runs before the fsmonitor daemon stop. The stop must stay before the rename: on Windows the daemon holds a handle on the worktree that would fail the rename, and git's graceful stop resolves the daemon by worktree path, so it cannot reach it after the move (verified: a healthy daemon self-exits and unlinks its socket within ~500 ms of the rename; the client from the renamed path reports "not running").
  • prune-gather stops shelling out: the main worktree comes from git worktree list ordering (documented: main lists first; list_worktrees() filters bare entries) instead of a git rev-parse --git-dir per worktree.

Criterion on the prune-4-8 fixture: dry_run_probe_cold 197 ms to 147 ms (-25%), dry_run_warm 110 ms to 62 ms (-43%), live 783 ms to 615 ms (-21%). A prune-12-8 one-shot timeline (24 candidates, live): wall 3.14 s to 2.09 s; per-worktree removals 170-210 ms to 112-134 ms, now dominated by the ~60 ms daemon stop. The rust-scale numbers in benches/CLAUDE.md are marked for re-measurement on the next fixture rebuild.

Consistency corner, unchanged in kind but wider in trigger: a worktree that becomes dirty after handle_removed_worktree_output starts (in practice: a pre-remove hook dirtying it) now fails at the pre-rename check, which is after the cd directive is written (and, in background mode, after the "Removing … in background" message and retention hint print), and the shell wrapper applies the cd file regardless of exit code. So in that corner the shell moves to the primary worktree while the dirty worktree is preserved with an error. The same outcome already existed for a file appearing between the refresh and the final check; nothing is removed in either case.

Testing: full pre-merge gate green (4,578 tests, no snapshot changes), plus a new shim-based test covering the skip path when a stale (Prunable) candidate's preparation fails at removal time. The hook-dirtied corner has no dedicated test; existing dirty-worktree removal tests cover the command-entry gate. The one remaining uncovered added line is the ? propagation closing the live scan closure (prune.rs:1023), which has no deterministic trigger.

This was written by Claude Code on behalf of max

max-sixty and others added 2 commits July 26, 2026 13:56
Prune now passes each candidate's scan-time removal plan through to the
serial removal instead of re-deriving it under the write lock; the
post-pre-remove safety refresh is deleted (prepare computes the display
verdict, and the pre-rename clean check plus the branch-delete CAS carry
the guarantees); the clean check moves before the fsmonitor-daemon stop,
so the daemon serves the status instead of a post-stop full re-stat; and
prune's gather reads the main worktree from list order rather than running
git rev-parse per worktree.

prune-4-8 criterion: probe-cold scan -25%, warm scan -43%, live -21%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A git shim fails 'worktree prune' so a Prunable candidate's preparation
errors at removal time; prune must skip it and exit successfully with the
branch intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@max-sixty
max-sixty marked this pull request as ready for review July 26, 2026 22:31

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the removal-chain refactor closely, and traced the data-safety invariants end to end rather than trusting the (thorough) PR description. Not auto-approving: this is worktrunk's deletion surface (src/git/remove.rs removal core + --force fallback, src/output/handlers.rs, src/commands/step/prune.rs), which per the repo's review policy is a human's call to merge — flagging that explicitly so silence isn't read as a rubber stamp.

The refactor looks correct. What I verified independently:

  • ensure_clean gate preserved exactly once on every non-force path. Foreground / silent-picker / detached-foreground now gate inside remove_worktree_with_cleanup; background gates in execute_instant_removal_or_fallback (pre-existing). None lost the check and none doubled it — and the old background path actually ran ensure_clean twice (refresh_removal_safety_after_pre_remove + execute_instant), so this reduces it to one. The gate still runs after execute_pre_remove_hooks_if_needed, so a hook-dirtied worktree is still caught.
  • Scan-time plan reuse is safe. The check_one read lock and try_remove write lock serialize on the same RwLock; the only load-bearing fields at execution (dirtiness, branch integration) are re-validated by the execution-time ensure_clean and delete_branch_if_safe's fresh-ref CAS. integration_reason is display-only, as documented. The Orphan change from unconditional removable = true to plan.is_some() matches the old net outcome (old try_remove re-prepared and skipped on error).
  • idx == 0 && !is_bare matches the documented list_worktrees() ordering guarantee (main first for normal repos; bare entry filtered, no main for bare) — equivalent to the old !is_linked() probe, including the submodule path-correction case.

One small non-blocking observation on the background path: because the clean check moved into execute_instant_removal_or_fallback (after print_message/print_hints in handle_named_removed_worktree_background), a hook-dirtied worktree removed in background mode now prints "Removing … in background" and the retention hint before failing the clean check, where previously the refresh gate failed before any chrome. No data loss (it still fails before the rename, no false "Removed"), just slightly noisier output in that corner — worth a glance to confirm it's intended.

@max-sixty
max-sixty merged commit 5ecdf2b into main Jul 27, 2026
40 checks passed
@max-sixty
max-sixty deleted the prune-perf branch July 27, 2026 00:34
max-sixty added a commit that referenced this pull request Jul 27, 2026
…in trim (#3622)

Re-measurement on the rebuilt `rust-prune-12-24` fixture after #3617
trimmed the removal chain: live one-shot ~12 s to ~2.9 s (two runs, 2.91
s / 2.89 s), worktree candidates ~155-210 ms each now that the status is
fsmonitor-served, branch-only ~40-100 ms. Full-cold dry-run unchanged in
shape.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request Jul 27, 2026
Resolves the conflicts with #3607 (path resolution) and #3617 (the
removal chain's one-check-per-guarantee refactor), and reworks the
retention onto their shape:

- One `live_sibling_checkout` predicate replaces the two divergent
  sibling scans. A sibling entry whose directory is already gone no
  longer retains the branch and names a path that isn't there.
- `wt merge` and `wt step prune` ask it too. Both reached the same ref
  deletion outside the guard; merge asserted the invariant in a comment
  instead of checking it.
- `wt step prune` targets candidates by path, matching `wt remove`.
  Targeting a stale entry by branch name resolved to a live worktree the
  same prune had skipped as too young, then removed it.
- A `-D` this refuses warns instead of passing quietly.
- #3480's duplicate hint points at `wt remove <path>`, now the safe
  answer, and `wt remove --help` states the retention.

Tests: remove (path, name, refused -D, pruned fallback, stale sibling
must not retain), prune (stale entry with a live sibling), merge
(duplicate checkout).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants