Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ syntax-highlighting = ["dep:tree-sitter", "dep:tree-sitter-bash", "dep:tree-sitt
# When enabled, run with NEXTEST_NO_INPUT_HANDLER=1 to avoid suspension.
# See CLAUDE.md "Nextest Terminal Suspension" section for details.
shell-integration-tests = []
# Enable benchmarks whose fixture is too large for hosted CI: the rust-scale
# prune fixture (~15 GiB of rust-lang/rust worktrees, cached in
# target/bench-repos/). The daily benchmarks workflow runs plain `cargo bench`
# and must never build it; opt in locally with
# `cargo bench --bench prune --features real-repo-benches`.
real-repo-benches = []
# Install git-wt binary so `git wt` works as a git subcommand
git-wt = []

Expand Down Expand Up @@ -341,6 +347,10 @@ harness = false
name = "remove"
harness = false

[[bench]]
name = "prune"
harness = false

[[bench]]
name = "picker_preview"
harness = false
Expand Down
124 changes: 124 additions & 0 deletions benches/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ cargo bench --bench list
cargo bench --bench time_to_first_output # all commands
cargo bench --bench time_to_first_output remove # just remove

# wt step prune (scan + removal on the squash-merged fixture)
cargo bench --bench prune # synthetic variants
cargo bench --bench prune --features real-repo-benches prune_real_repo # rust scale (~15 GiB cached fixture)

# Picker preview pre-compute (wt switch preview workload)
cargo bench --bench picker_preview # all variants
cargo bench --bench picker_preview warm # warm only
Expand Down Expand Up @@ -57,6 +61,12 @@ startup latency without output rendering or post-output work (mismatch warnings,

Supported commands: `switch`, `remove`, `list`.

`wt step prune` deliberately has no `WORKTRUNK_FIRST_OUTPUT` hook: its first
output is data-dependent (the dry-run path collects and sorts every check
result before printing anything, so e2e ≈ time-to-first-output already; the
live path streams whichever check lands first). Use `benches/prune.rs` for
cadence-tracked numbers and the `prune-*` spans (below) for phase attribution.

## WORKTRUNK_PREVIEW_BENCH

Setting `WORKTRUNK_PREVIEW_BENCH=1` runs `wt switch`'s interactive picker prelude
Expand Down Expand Up @@ -133,6 +143,7 @@ untracked files.
|---------|------------|-------|
| `wt list` | Yes | Post-skeleton tasks. Exits early under `WORKTRUNK_SKELETON_ONLY=1` / `WORKTRUNK_FIRST_OUTPUT=1` — those skip the writing phase. |
| `wt remove` | Yes | `prepare_worktree_removal` → `compute_integration_lazy` writes `is-ancestor` / `has-added-changes` / `merge-add-probe` whenever `BranchDeletionMode` is not `ForceDelete` (CLI `--force` is `force_worktree`, not `--force-delete`). |
| `wt step prune` | Yes | Every scanned worktree/branch runs `integration_reason` → the same probe writes as `wt remove`. First scan after new commits is cold; re-runs are warm (`prune_e2e/dry_run_cold` vs `dry_run_warm`). |
| `wt switch <branch>` | No | No sha_cache writers on the direct-switch path. |
| `wt switch` (picker) | Yes | Preview pre-compute writes `picker-preview/{log,branch-diff,upstream-diff}-…` entries. Exercised under `WORKTRUNK_PREVIEW_BENCH=1` / `WORKTRUNK_PICKER_DRY_RUN=1`. |
| `wt` (completion via `COMPLETE=$SHELL`) | No | Only `for-each-ref` + worktree list. |
Expand Down Expand Up @@ -160,10 +171,116 @@ worth measuring at CI cadence.
- Cold cache penalty: ~4x slower for single worktree
- Scaling: Warm cache shows superlinear degradation, cold cache scales better

## Recording `wt remove` / `wt step prune` staging

The removal commands interleave serial per-target work with parallel scans and
detached background processes; a single e2e number hides which phase moved.
Record them in two layers:

**Criterion cadence** — `benches/remove.rs` and `benches/prune.rs`. Expected
numbers on an M-series Mac (`prune-4-8` fixture: 4 squash-merged worktrees +
4 squash-merged branches as candidates, 8 two-sided-diverged worktrees + 8
diverged branches as backdrop, 200 commits, 100 files; `prune_real_repo` runs
a warm dry-run on the `prune-real` fixture — a rust-lang/rust clone with 12
squash-merged candidate pairs + 24 diverged worktrees and 24 diverged
branches, i.e. 36 linked worktrees, cached and self-repairing in
`target/bench-repos/rust-prune-12-24/`. That group is opt-in —
`cargo bench --bench prune --features real-repo-benches prune_real_repo` —
because its ~15 GiB fixture must never build on a hosted CI runner):

| Variant | Expected | What it measures |
|---------|----------|------------------|
| `prune_e2e/dry_run_cold` | ~160 ms | full parallel scan, integration probes uncached |
| `prune_e2e/dry_run_warm` | ~90 ms | steady-state re-scan, probes hit sha_cache |
| `prune_e2e/live` | ~620 ms | cold scan + serial removal of the 8 candidates (~60 ms each, under the scan write lock) |
| `prune_real_repo/dry_run_warm` | ~0.3–0.8 s | steady-state scan of 72 items (36 worktrees + 36 branches) at 331k-commit scale |
| `remove_e2e/first_output` | ~86 ms | single-target validation up to first output |

Cold and live at rust scale are **one-shot timelines, not criterion groups**
(a cold criterion iteration costs ~1 min in re-hashing statuses alone; a live
one consumes the candidates). Expected one-shots on the `prune-real` fixture:

- **cold dry-run ~5.5 s wall** (~46 s CPU over 472 subprocesses absorbed by
the rayon pool) — dominated by stat-cold `git status` at ~4.5 s per fresh
worktree; the probes are `merge-base --is-ancestor` ~40 ms and `merge-tree
--write-tree` ~130 ms (vs 4–25 ms synthetic, where shallow history walks
bottom out at subprocess-spawn cost)
- **live ~12 s wall** — all 24 removals serialize under the scan write lock
inside the `prune-scan` window: each of the 12 worktree candidates takes
~0.5–1.7 s (pre-remove re-checks plus drain waits), branch-only candidates
~50 ms

This is the "prune takes many seconds" experience users report: worktree
count × stat-cold statuses bounds the scan, and removals extend it serially.
The synthetic fixture can't show it — its statuses are milliseconds — so
scale-sensitive changes need a one-shot on `prune-real` (or
`wt-perf timeline -- -C <repo> step prune --dry-run` on a real repo) alongside
the criterion cadence. All rust-scale numbers are I/O-bound and move with
ambient machine load (sibling builds, Spotlight): treat them as shape, not
thresholds, and read criterion "regressed" verdicts on this bench against
`uptime` before believing them.

Cold prune benches pair `invalidate_caches_auto` with
`restore_worktree_indexes` (see "Deleting a worktree's index isn't a cold
cache" under Cache Handling); the dry-run variants assert the listed
candidate count so that degradation can't come back unnoticed.

**Phase attribution** — `wt-perf timeline` plus the removal spans. Prune emits
`prune-gather` (worktree+branch enumeration), `prune-scan` (the whole parallel
check region), one `prune-check:<ref>` per scanned item, and one
`prune-remove:<label>` per removed candidate; `wt remove` emits
`internal-sweep` around its end-of-command janitor. The `prune-remove` spans
sit *inside* the `prune-scan` window on the live path — each removal takes the
scan lock's write side, so a span covers the wait for in-flight checks to
drain *plus* the removal itself: read it as "how long this removal stalled the
run", not as pure removal work.

```bash
cargo run -p wt-perf -- setup prune-4-8 --path /tmp/prune-repo --persist
# A freshly built fixture is already probe-cold (empty sha_cache); don't use
# `timeline --cold` here — it deletes worktree indexes, which flips prune's
# clean-worktree gate and drops the worktree candidates from the run.
cargo run -p wt-perf -- timeline -- -C /tmp/prune-repo step prune --dry-run --min-age 0s
cargo run -p wt-perf -- timeline -- -C /tmp/prune-repo step prune --min-age 0s
```

**Live prune at real scale is a one-shot timeline, not a criterion group** —
each live run consumes the candidates, and re-creating them on rust costs
minutes per iteration. The `prune-real[-M-U]` fixture is built for this
workflow: it lives in `target/bench-repos/` (no `--path`), and after a live
prune the next `wt-perf setup prune-real` or `prune_real_repo` bench run
detects the consumed candidates and re-creates just them (~1 min) instead of
rebuilding the ~15 GiB fixture. Don't run two builders concurrently (a bench
and a setup racing can wipe each other's half-built tree), and don't
`wt-perf invalidate` it — deleting worktree indexes flips prune's
clean-worktree gate; the next setup/bench call heals that, but the run you
invalidated for measures a degraded scan:

```bash
cargo run -p wt-perf -- setup prune-real # build or validate/repair the cache
cargo run -p wt-perf -- timeline -- -C target/bench-repos/rust-prune-12-24/repo step prune --min-age 0s
cargo run -p wt-perf -- setup prune-real # repair the consumed candidates
```

**The `wt remove` exit-delay is machine-dependent and invisible to benches.**
After its last message, `wt remove` runs an in-process sweep
(`run_internal_sweep`) that enumerates `git fsmonitor--daemon` processes
*machine-wide* and resolves each one's socket with a ~50 ms `lsof` call —
sequential, before exit, while the shell wrapper waits on the process. On a
machine with N live daemons that appends roughly `N × 50 ms` of post-output
latency (measured: 115 daemons → 5.8 s after 0.4 s of actual removal output);
on daemon-free bench/CI machines it costs nothing, so `remove_e2e` never sees
it. To observe it, run `wt-perf timeline -- remove <branch>` on a real machine
and read the `internal-sweep` span and its `lsof -a -p …` children; the
`fsmonitor sweep: resolving sockets for N daemon(s)` debug line gives the
count.

## Output Locations

- Results: `target/criterion/`
- Cached rust repo: `target/bench-repos/rust/`
- Cached rust prune fixture: `target/bench-repos/rust-prune-<M>-<U>/` (repo +
sibling worktrees + `round` counter for candidate re-creation)
- HTML reports: `target/criterion/*/report/index.html`

## Performance Investigation with wt-perf
Expand All @@ -182,6 +299,13 @@ cargo run -p wt-perf -- setup typical-8 --persist
# branches-N-M - N branches, M commits each
# divergent - 200 branches × 20 commits (GH #461 scenario)
# mixed-W-B - W worktrees + B branches in varied states (the `full` fixture)
# prune-M-U - M squash-merged candidates + U two-sided-diverged
# worktrees/branches (the `wt step prune` workload; see
# benches/prune.rs)
# prune-real[-M-U] - rust-lang/rust clone + M squash-merged candidate pairs
# + U diverged worktrees/branches (default 12-24), cached
# and self-repairing in target/bench-repos/ (no --path);
# first run clones from the network
# picker-test - Config for wt switch interactive picker testing

# Invalidate caches for cold run
Expand Down
Loading
Loading