Commit Graph

85 Commits

Author SHA1 Message Date
Maximilian Roos 897f7d7193 fix(tests): extend the spawn pin to benches; correct the pin's cost note (#3792)
Follow-up to #3784, prompted by a history audit of the spawn-flake
family. Benches still spawned `env!("CARGO_BIN_EXE_wt")` — the uplifted
path the suite stopped spawning — so a concurrent build could fail a
bench run's spawns; all 11 sites now route through `wt_bin()` and
`test_wt_spawns_are_pinned` scans `benches/` too, making the rule
exceptionless. The pin's docstring also claimed the hardlink shares the
`deps/` artifact's inode: true where cargo uplifts by hardlink (Linux),
but macOS uplifts by copy-on-write clone — the pin keeps the clone,
whose blocks stay shared with `deps/` (measured: cloning the 70 MB
binary consumes 8 KB), so the no-cost conclusion stands with the
mechanism now stated per platform, plus why nothing sweeps the
directory.

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:36:11 -07:00
Maximilian Roos 00ab0ffa26 Canonicalize benchmark fixtures and variants (#3761)
Benchmark fixtures still encoded the benchmark that first needed each
repository state, which left overlapping recipes and variants after the
earlier harness consolidation. This change reduces the fixture catalog
to two provenance-based bases: `Generated` builds an ordinary Git
repository locally, while `Imported` copies the pinned `rust-lang/rust`
corpus. Worktree, branch, and remote-ref populations remain parameters
on `Generated`; prune candidates and backdrop are overlays that work
with either base.

The generated base deliberately combines heterogeneous worktree states,
history-spread branches, and optional remote refs so ordinary list,
completion, picker, first-output, alias, remove, and prune benchmarks
can share it. Imported history-spread branches and clean base-tip
worktrees carry their own commits, preserving the base populations
without making them incidental prune candidates when overlays advance
the default branch.

The benchmark matrix now keeps single-factor contrasts: list scaling
uses the 1- and 8-worktree endpoints; alias dispatch has a startup
floor, two population endpoints, and one warm/cold variable-resolution
pair; completion keeps one full-surface case; remove and prune vary
cache or hook state only where the command exercises it. Historical
recipes, redundant cache rows, and intermediate scaling points are
removed. Manual setup paths live under `target/`, and the benchmark
guide documents the resulting fixture and cache model.

Tests: `cargo run -- hook pre-merge --yes` after merging current `main`
(4,571 tests); targeted Criterion test-mode runs; `cargo test -p
wt-perf`; benchmark check, clippy, formatting, and diff checks.

> _This was written by Codex on behalf of max-sixty_
2026-08-08 13:38:00 -07:00
Maximilian Roos 970976bd32 Consolidate benchmark recipes and cases (#3721)
Benchmark fixtures had accumulated around individual call sites, leaving
the same repository shapes and command modes expressed several ways.
This change makes repository state the organizing concept: benchmark
groups select semantic `FixtureRecipe`s, share table-driven cases, and
retain separate fixtures only when a controlled contrast, destructive
precondition, or disproportionate setup cost requires one.

The real-repository list benchmarks now share one pinned
`rust-lang/rust` fixture with eight worktrees and fifty branches spread
across history. The list matrix keeps default, branch, warm, and cold
coverage without maintaining several “real” repository handles. Remove
and prune cases share the same case machinery, while the destructive
large-repository prune state remains separate.

The scheduled workflow now converts Criterion estimates directly with
`jq`, removing the one-off Python converter and its tests. The benchmark
guide records the canonical-fixture principle and the remaining
recipe-to-group mapping.

Tests: `cargo run -- hook pre-merge --yes` (4,551 tests); `cargo bench
--bench list large_repository -- --test`; `cargo test -p wt-perf`;
benchmark check, clippy, formatting, and diff checks.

> _This was written by Codex on behalf of max-sixty_.
2026-08-05 10:30:34 -07:00
Maximilian Roos 28a7c00448 fix(prewarm): faithful -vv traces and a universal git-config preload (#3705)
`wt-perf timeline -- config state get` showed three `git config --list
-z` forks per invocation, despite `RepoCache.all_config` promising one
bulk read. Root-causing attributed the three to three separate
`Repository` constructions (the command's, the help pager's in
`pager::git_config_pager`, the `-vv` diagnostic epilogue's), each with a
fresh `RepoCache` — and to two mechanisms that kept the prewarm preload
from serving them:

1. **`-vv` disabled prewarm.** `prewarm_at` fast-pathed on
`GIT_COMMON_DIR_CACHE.contains_key` alone. Under `-vv`,
`log_files::init` constructs a `Repository` during `logging::init` —
before `Repository::prewarm()` runs in `main` — which populates exactly
that key, so prewarm skipped the git-config and user-config preloads
entirely. Every `-vv` trace therefore overstated the on-demand config
forks of a normal run (a plain repo does one `git config --list -z`
total, not three), which is what produced the original report. Each
prewarm thread now gates on the cache it populates.

2. **`extensions.worktreeConfig` repos never got the preload.**
`prewarm_git_config` declines the discovery-path read there (#2779: it
misses main-worktree `config.worktree` overrides like `core.bare`), so
every fresh construction re-forked from the common dir on first
`all_config()` access. A post-pass after the prewarm threads join now
re-runs `git config --list -z` from the resolved `git_common_dir` —
byte-for-byte the invocation `all_config` would fork on demand — and
preloads that merged map, so these repos get the same memory-hit
constructions as everyone else.

Fork counts for `wt config state get` (start+complete pairs
deduplicated, verified via `RUST_LOG=worktrunk=debug` and `wt-perf
timeline`):

| Repo class | Before | After |
|---|---|---|
| plain repo, normal run | 1 | 1 |
| `worktreeConfig` repo, normal run | 3 | 2 |
| `worktreeConfig` repo, `-vv` | 3 (prewarm skipped) | 2 (prewarm
traced, zero on-demand) |

The two on-demand construction sites this obviates fixing individually:
the pager's `Repository::current()` and the diagnostic's now both
consume the preload, so no plumbing of `&Repository` through
`show_help_in_pager` was needed.

Timing, release builds of `bbd24fb8f` vs this branch on the worktrunk
dev repo (`worktreeConfig` class), hyperfine `-N --warmup 10 --runs 80`,
load < 4: `wt config state get` 73.4 ms ± 4.6 → 68.7 ms ± 3.8 (−4.7 ms
mean, consistent with the one saved fork). Plain repos are unchanged by
construction (same fork count and code path).

Tests: the #2779 decline test now pins the inverse property (the preload
lands and holds the merged common-dir map, with `core.bare = true` as
the poison pin); new tests pin that a `Repository` constructed before
prewarm no longer suppresses the config preload, that a partially-warm
prewarm runs only the cold threads without overwriting an existing
preload (and a fully-warm repeat early-returns), that a failed
common-dir re-read (missing dir, corrupt config) leaves the preload
empty for on-demand `all_config` to surface, and that a prewarm-less
construction still reads `all_config` from the common dir (the on-demand
#2779 branch). The `is_bare`/`repo_path` regression tests now exercise
the preload-consuming path.

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:05:51 -07:00
Maximilian Roos aba7bad089 perf(completion): overlap the git reads a Tab press waits on (#3664)
Shell completion forks a whole `wt` process on every Tab press, with the user's finger still on the key, and it has nowhere to hide work: nothing paints progressively, no cache survives the process, and no shell caches a dynamic completion. It was doing every git read in sequence.

Measured on `mixed-80-80-1400`, M4 Max, same binary before and after:

| | before | after | |
|---|---|---|---|
| `wt switch <Tab>` | 45.7 ms | 27.4 ms | 1.66× |

On this repo's own checkout, `wt switch <Tab>` goes 47.9 ms → 29.0 ms; `wt <Tab>` (subcommand names, no branch work) goes 22.2 ms → 12.8 ms.

## The benchmark first

`benches/completion.rs` ran against fixtures carrying two remote-tracking refs and no worktrees — a shape nobody has. It never reached the completer's 100-candidate threshold, where every remote-only branch is discarded *after* being scanned, so the most expensive call on the path was measured at a size that hid it.

It now runs one repo, on the `mixed` fixture `full` already uses: `mixed-80-80-1400` — 80 worktrees in four states, 80 more branches forked across 200 commits of history, and 1400 remote-tracking refs. That covers all three categories `BranchCompleter` distinguishes, puts both candidate slow calls (`for-each-ref refs/remotes/` and `worktree list --porcelain`) in one process at a size where each is expensive, and lands past the 100-entry threshold. `completion_switch/mixed` reports 23.1 ms; the fixture builds in 9.7 s.

The remote-ref count is the one dimension `mixed` was missing, so it goes on `create_mixed_repo` as an optional third component of the handle. `full` passes 0 and is unaffected, so its tracked series has no discontinuity — and its own version of the same gap (`wt list` scans `refs/remotes/` too, at two refs) becomes a one-argument change if we want it.

`completion_switch`'s own history does break, and knowingly: its two ids (`branches_only`, `with_worktrees`) collapse into one `mixed`, and `criterion-to-jsonl.py` keys the nightly gist rows by `<group>/<id>`, so those two series stop accumulating. Worth paying — they measured the fixture that hid this cost in the first place, so carrying them forward would mean maintaining a benchmark that structurally cannot see the thing it exists to watch.

Worth calling out explicitly, since #3669 landed while this was open and states that it preserves all 41 existing Criterion IDs: this PR is the one place that number changes, deliberately, and 41 becomes 40.

Deliberately not a variant matrix. Completion has no phases worth timing apart, and the calls within a wave overlap, so no split of the wall time says which one moved — localizing a regression is a trace's job, as `benches/CLAUDE.md` already says for `full`.

## The changes

- **Call `Repository::prewarm` on this path.** It already exists for exactly this, filling the git-discovery, git-config, and user-config caches on three threads — but completion answers and returns long before `main` reaches it, so those reads were each a separate serial fork.
- **Run the three scans behind `branches_for_completion` concurrently.** They are independent git calls the function only joins in memory; in sequence the user waits for their sum where they could wait for the max.
- **Stop scanning `refs/remotes/` where a remote-only branch can never be offered** (`wt remove`, worktree-only arguments). The completer decides that once, up front, and it drives the scan as well as the filter.
- **Return to the completion handler before `main` builds the rayon pool** — `available_parallelism() * 2` OS threads, spawned eagerly, on a path that reaches no rayon.

`wt switch <Tab>` goes from six serial forks to five in two concurrent waves; `wt remove <Tab>` to four.

## A bug found on the way

`branches_for_completion` groups remote refs through a `HashMap` and sorts by timestamp with a stable sort, so on tied timestamps the order is whatever that process's hashing produced — three consecutive completions print three different orders. Fixed by breaking the tie on name, in its own commit.

## Verification

- Candidate sets byte-identical to the pre-change binary across bash/fish/zsh/nu at 64 completion points — including the 100-candidate threshold either side, `--create` suppression, and outside a git repo.
- `cargo run -- hook pre-merge --yes` green on the merge commit, after merging current `main` in: 4491 tests, all lints.
- `cargo bench --bench completion -- --test` green — worth running explicitly now that `run_and_check` asserts the measured subprocess succeeded, since the assert is new to this bench.

Merged `main` twice while this was open. The second, #3669, consolidated the bench harness onto a shared `FixtureRepo` plus `wt_command` / `run_and_check` and touched all four bench files this branch changes; every conflict was resolved toward main's harness. Notably the completion bench now uses main's `run_completion`, which asserts the measured subprocess actually succeeded — the version here did not, so a `wt` that failed under benchmark would have been silently timed.

## Review feedback folded in

The draft-stage review caught the `add_remote_refs` rationale overclaiming, and it was right. The comment said the round-robin makes the refs "name distinct objects", with the scan reading "one commit object per ref". It can only spread over the history it has: at 200 base commits, 1400 refs land 7 apiece on ~200 distinct commits, and git parses each object once and reuses it for the rest of the `for-each-ref`. So the scan pays ~200 parses plus a cheap iteration hit per ref, not 1400 parses.

That gap is now stated rather than papered over. Closing it would need a history as deep as the ref count, and `BASE_COMMITS` is shared with `full` — deepening it would slow that fixture and shift the repo `full` has been measured on, to sharpen a per-object parse this bench isn't primarily about. The ref count is the dimension it exists to vary. (The review raised this against `long_lived_clone`, the parallel fixture since deleted; the same overclaim had survived into the shared-fixture version.)

## Not addressed

Two things from the report that prompted this, both left alone deliberately:

- The dominant per-fork tax on the reporter's machine is their `~/.gitconfig` symlink chain landing on NFS, re-resolved by every git child. That's local environment, not code — though it is exactly why overlapping the forks helps them more than the numbers above suggest.
- `worktrunk.state.<branch>.*` markers accumulate without bound: on this checkout, 79 branches have markers and 72 of those branches no longer exist. Real, but a separate subsystem with its own design question about when a marker may be dropped.

> _This was written by Claude Code on behalf of @max-sixty_
2026-07-30 11:02:18 -07:00
Maximilian Roos 7fe28bf574 Consolidate benchmark fixtures and harness (#3669)
Benchmark targets had each reimplemented temporary-repository ownership,
linked-worktree paths, subprocess isolation, and warm/cold loops. This
adds a shared `FixtureRepo` and canonical command/cache helpers while
leaving each scenario-specific workload and destructive lifecycle
explicit.

All 41 existing Criterion IDs, fixture dimensions, command arguments,
and cache ordering are preserved. The completion benchmark now also
fails when its measured subprocess fails instead of silently accepting
the result.

Tests: `cargo run -- hook pre-merge --yes`; `cargo test -p wt-perf`;
`cargo check --benches -p worktrunk -p wt-perf`; representative
Criterion test-mode runs for completion, list, first-output, remove, and
prune.

> _This was written by Claude Code on behalf of max_.
2026-07-30 10:32:08 -07:00
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
Maximilian Roos e548196f89 docs(bench): refresh rust-scale prune one-shots after the removal-chain 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>
2026-07-26 19:33:17 -07:00
Maximilian Roos 5ecdf2bb78 perf(remove, prune): one check per guarantee in the removal chain (#3617)
`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_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:34:14 -07:00
Maximilian Roos 166e1190ba refactor(perf): canonicalize wt-perf prune-fixture layering + completion config (#3548)
## What

Three behavior-preserving canonicalizations of the `wt-perf` benchmark
fixtures, from a design pass on "why does prune need its own generator,
and can the fixtures consolidate?"

1. **Share the prune-population layer.** `create_prune_repo_at`
(synthetic) and `create_prune_real_repo_at` (rust-scale) both layered
the same two populations onto their base repo — `add_diverged_backdrop`
(the scanned-but-never-removed backdrop) + `add_squash_merged` (the
removable candidates). Extracted into `add_prune_populations`, so the
base repo (synthetic `create_repo_at` vs. rust clone) is now the *only*
thing the two fixtures differ in, spelled out once instead of twice.

2. **Canonicalize `completion.rs` onto `RepoConfig::branches`.** Its two
inline `RepoConfig` literals were exactly `RepoConfig::branches(50, 0)`
(one with `worktrees` overridden), so they now use the canonical preset
via struct-update instead of respelling all seven fields.

3. **Document the fixture → bench mapping.** `benches/CLAUDE.md` listed
the `wt-perf setup` handles in one section and the bench groups in
another with nothing connecting them, so a bench and its fixture (`full`
and `mixed`) read as overlapping names for the same thing. Added a
mapping table and the rule behind it.

## Why this is the whole safe consolidation

The design question was whether prune could drop its bespoke generator
for a "standard one with different params" — ideally one diverse repo
serving prune *and* the other benches. The answer is that prune's
generator is **already** a thin composition (`create_repo_at` + two
additive layers), and it can't fold into the shared `mixed`/`full`
fixture without breaking three prune-specific invariants: a
known/cleanly-separated candidate count (the destructive `live` path
re-creates exactly the consumed candidates), untracked-only dirt (the
`git reset --mixed` index-heal depends on it — `mixed`'s staged-dirt
states violate it), and a provably-unintegrated backdrop. And benchmark
fixtures optimize for *attribution*, not realism: the uniform
single-axis fixtures (`worktree_scaling`, etc.) exist precisely because
the diverse `full` fixture can't localize a regression, so diversifying
them would lose that.

On the `full`/`mixed` naming specifically: they can't be canonicalized
to one name, because fixtures and benches are **many-to-one** —
`RepoConfig::typical` alone backs five bench groups (`skeleton`,
`worktree_scaling`, `first_output`, `picker_preview`, `remove_e2e`), two
of which sit in the same file measuring different things. `full`/`mixed`
only looks 1:1 by coincidence. Renaming also isn't free:
`.github/scripts/criterion-to-jsonl.py` keys each time-series row by the
criterion output path (`<group>/<id>`) for the daily gist append, so a
rename orphans that series. Documented rather than renamed.

## Proposed follow-up (not in this PR)

The one remaining real duplication is two *implementations* of
"two-sided-diverged population forked across history depth":
`add_diverged_backdrop` (plumbing-based, scales to rust) vs. the
diverged branch states inside `create_mixed_repo` (checkout-based,
synthetic-only). They can't naively merge — the checkout path would make
`prune-real` take minutes per branch — but a shared plumbing primitive
could back both. That rewrites a carefully-tuned fixture feeding the
`full` bench with **no shape-assertion test to catch drift**, so I'd
land a fixture-shape test first (count refs by state + assert
integration status). Happy to do it if wanted.

## Verification

fmt `--check` clean; `clippy -p wt-perf --all-targets` and `clippy
--bench completion` clean; `pre-commit` clean on the doc; `cargo test -p
wt-perf` passes (incl. the prune-fixture tests
`squash_merged_fixture_is_content_integrated` /
`prune_fixture_state_classifies_lifecycle`, which exercise
`create_prune_repo_at` → `add_prune_populations`); all benches build.
Table contents verified against the code by grep.

> _This was written by Claude Code on behalf of Maximilian Roos_

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 18:40:00 -07:00
Maximilian Roos a7c4a7a120 refactor(wt-perf): place fixtures under target/wt-perf, not the cache dir (#3547)
## What

Move every `wt-perf` on-disk fixture out of the per-user cache dir and
under the cargo target dir at `<target>/wt-perf/`, resolved by a renamed
`wt_perf_fixture_dir()`:

- `setup <config>` fixtures → `<target>/wt-perf/<config>` (was
`~/.cache/wt-perf/<config>`).
- The rust-lang/rust clone and `prune-real` fixtures →
`<target>/wt-perf/bench-repos/` (was `~/.cache/wt-perf/bench-repos/`).
- `wt_perf_cache_dir()` → `wt_perf_fixture_dir()`. The target dir is
derived from the **running executable's own path** (it lives inside
whichever dir cargo built into — `<target>/debug/wt-perf`,
`<target>/release/deps/<bench>`), so it honors `CARGO_TARGET_DIR`, a
config-file `build.target-dir`, and cargo-llvm-cov's
`target/llvm-cov-target/` — none of which a bare `CARGO_TARGET_DIR` env
read covers. Falls back to `<workspace>/target` if the binary isn't
under a recognizable profile dir. The `etcetera` dependency is dropped.
- The `WT_PERF_CACHE_DIR` env override (and the empty-value guard it
required) is removed; the benchmarks workflow now caches the
deterministic `target/wt-perf/bench-repos` path directly.
- In-process throwaway fixtures (`create_repo`) are unchanged — they
keep using `tempfile::TempDir`.

This reverses the location decision from #3542, which had moved these
into `~/.cache/wt-perf`.

## Why

`target/` is the conventional home for build/test-generated artifacts —
gitignored, reaped by `cargo clean`, and already where criterion writes
(`target/criterion`). Keeping every wt-perf fixture there gives one
predictable location under the repo that `cargo clean` fully resets.
Deriving the target dir from the running binary (rather than assuming
`<workspace>/target`) keeps that property intact even when the target
dir is relocated.

## Tradeoff (accepted)

`target/` is **not** shared across git worktrees (worktrees don't share
it) and is wiped by `cargo clean`. So the ~15 GiB `prune-real` rust
clone re-clones per worktree and after every `cargo clean` — the cost
#3542 avoided by using the cache dir. This is deliberate and documented
on `wt_perf_fixture_dir`; it's cheap for the synthetic `setup` fixtures,
which rebuild in seconds.

## Testing

- `cargo build -p wt-perf --all-targets`, `cargo test -p wt-perf` —
clean.
- New unit test `target_dir_from_exe_finds_cargo_target` covers the
resolution logic (relocated `CARGO_TARGET_DIR`, bench binary under
`release/deps/`, cargo-llvm-cov's nested target, closest-profile-wins,
and the outside-any-target fallback).
- `pre-commit run --all-files` (fmt, clippy, yaml, typos, cargo-lock,
custom hooks) — clean.
- Smoke-tested end-to-end: `setup branches-2` lands at
`<worktree>/target/wt-perf/branches-2` and writes nothing to
`~/.cache/`; a copy of the binary run from a fake `<dir>/debug/wt-perf`
correctly resolves fixtures to `<dir>/wt-perf/`, proving a relocated
target dir is honored.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 13:44:50 -07:00
Maximilian Roos 4121dc979c refactor(wt-perf): cache fixtures in the platform cache dir, not /tmp or target/ (#3542)
## What

Move `wt-perf`'s benchmark/debug fixture repos out of
`std::env::temp_dir()` and `target/bench-repos/` into the per-user cache
dir, resolved by a new `wt_perf_cache_dir()`:

- `$WT_PERF_CACHE_DIR` if set, else `<cache>/wt-perf` via
`etcetera::choose_base_strategy().cache_dir()` — `~/.cache/wt-perf` (or
`$XDG_CACHE_HOME/wt-perf`) on Linux **and** macOS. This matches
worktrunk's own base-dir convention (`src/config/user/path.rs` resolves
user config through the same XDG-on-macOS strategy).
- `setup <config>` fixtures → `<cache>/<config>` (was
`temp_dir()/wt-perf-<config>`). `--path` still overrides.
- Real cloned fixtures (`prune-real`, the rust-lang/rust clone) →
`<cache>/bench-repos/` (was `target/bench-repos/`).
- In-process throwaway fixtures (`create_repo`) are unchanged — they
keep using `tempfile::TempDir`.

## Why

Both old homes were wrong for a *reused* fixture (these are given stable
names, persist between runs, and are referenced from docs and `-C
<path>` — that's a cache, not a temp file):

- **A fixed name in a shared `/tmp` (Linux)** collides across users:
`/tmp` is sticky (`1777`), so a second user's `setup` hits
`remove_dir_all().unwrap()` on a dir they don't own → `EPERM` → panic.
It's also a predictable-path/TOCTOU hazard, and `systemd-tmpfiles` reaps
`/tmp` per-file at 10 days by `max(atime,mtime,ctime)` — on a `noatime`
mount an actively-*read* fixture still loses cold
`.git/objects/pack/*.pack` mid-use → silent git corruption.
- **On macOS**, `temp_dir()` is already `/var/folders/.../T` (per-user,
`0700`), so the docs' `/tmp/wt-perf-*` paths were simply wrong there.
- **`target/`** is per-worktree (git worktrees don't share it) and wiped
by `cargo clean`, so the ~15 GiB rust clone was re-cloned per worktree —
worst for the most expensive fixture, in a worktree-heavy workflow.

The cache dir is per-user (no collision, no TOCTOU), stable and
discoverable (docs/`-C` references still work), shared across worktrees
(clone once per machine), and survives `cargo clean` — matching sccache,
cargo, rustup, Go, Bazel, and Hugging Face, which all place large
reusable caches there rather than in `/tmp`.

## Migration notes

- First bench/setup run after this rebuilds the cache under the new
location (the old `target/bench-repos/` is no longer read). Existing
fixtures can be dropped with `rm -rf target/bench-repos`.
- `.github/workflows/benchmarks.yaml` sets `WT_PERF_CACHE_DIR` and
caches `$WT_PERF_CACHE_DIR/bench-repos`, so the cached path and the path
wt-perf writes stay pinned together (no drift if a runner sets
`$XDG_CACHE_HOME`); key unchanged.
- Docs (`benches/CLAUDE.md`, `src/commands/CLAUDE.md`) updated to the
new paths, noting that `wt-perf setup` prints the exact path.

## Testing

- `cargo build --workspace --all-targets`, `cargo clippy --workspace
--all-targets --features shell-integration-tests -- -D warnings` —
clean.
- `cargo test -p wt-perf` — passes.
- Smoke-tested default resolution (`~/.cache/wt-perf/…` on macOS),
`$WT_PERF_CACHE_DIR` override, and the `prune-real --path` rejection
(now names the resolved cache path).

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 03:05:10 -07:00
Worktrunk Bot 1ebd7b043d docs: correct stale references found in nightly survey (#3490) 2026-07-16 02:16:48 -07:00
Maximilian Roos c310767216 refactor(bench): canonicalize prune cold to probe-cold via bench_wt (#3437)
Prune's Criterion "cold" had two structural problems: it
over-invalidated (deleting worktree indexes models a state users never
see, and needed the `restore_worktree_indexes` pairing just to keep
prune's clean-worktree gate from silently dropping candidates), and its
per-iteration stdout assertions kept it off the canonical `bench_wt`
runner. This PR fixes both.

**Probe-cold is now the canonical prune cold state.**
`invalidate_probe_caches` clears only `.git/wt/cache/`, the exact state
a repo is in right after fetching new commits — probes re-run at real
cost, git's own caches (indexes with stat data, commit graph,
`worktrunk.default-branch`) stay warm. `bench_wt` picks its invalidation
via a new `CacheState` enum (`Warm` / `Cold` / `ProbeCold`), and every
prune dry-run arm is now a `bench_wt` one-liner.
`restore_worktree_indexes` is demoted to a private fixture-healing
helper.

**Verification moved out of the timed loops.** Timed iterations assert
only exit status, like every other bench. Fixture correctness is pinned
once after setup: `verify_candidates` runs one dry-run that must list
exactly the expected candidates (8 synthetic, 24 rust-scale, 0 on the
live fixture's backdrop). A live run that removes nothing trips the next
iteration's candidate re-creation (branch-name collision) instead of
being silently timed.

**The rust-scale cold cell is now repeatable.** `prune_e2e/dry_run_cold`
is renamed to `dry_run_probe_cold` (the metric changed meaning, so the
old gist series ends deliberately), and a new
`prune_real_repo/dry_run_probe_cold` measures at rust scale what was
previously a hand-run one-shot — feasible because probe-cold iterations
cost ~0.6 s where full-cold costs ~1 min. Measured on a quiet machine,
all with tight CIs: synthetic probe-cold 159 ms / warm 96 ms / live 704
ms; rust-scale warm 247 ms / probe-cold 630 ms.

## Testing

All five bench arms run end-to-end with the fixture checks passing
(synthetic groups plus the feature-gated rust-scale group against a
freshly rebuilt 13 GiB fixture). `cargo test -p wt-perf`, clippy on both
feature paths, and the rustdoc `-Dwarnings` gate all pass. Benchmarks
don't run on PR CI; the daily workflow picks up the renamed series.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:37:18 -07:00
Maximilian Roos 350ea1f6a8 refactor(perf): make wt-perf setup persist-only and trim the CLI/lib surface (#3433)
Follow-up to #3403, applying the deferred `--persist` decision and two
sibling subtractions found sweeping the perf tooling for the same shape
(a vestigial option or dual path guarding nothing).

- **`wt-perf setup` is persist-only.** The `--persist` flag and the
interactive "Press Enter to clean up (or Ctrl+C to keep)" path are gone
— it was the one blocking, stdin-reading path in an otherwise scriptable
tool, and it guarded little: default-path fixtures land in
`/tmp/wt-perf-<config>`, which the next `setup <config>` run already
wipes and rebuilds. `prune-real` never offered cleanup, so setup's exit
behavior is now uniform.
- **`timeline --repo` deleted.** It only restated the traced repo:
`run_timeline` resolves the repo from `-C`/cwd, `--repo` defaulted to
exactly that, and the only documented use passed the identical path to
both flags. `--cold` now invalidates the repo being traced — the only
coherent target.
- **`wt_perf` lib surface tightened.** Seven helpers plus
`PruneFixtureState` had no callers outside `lib.rs` and are now private
(`ensure_rust_repo`, `clone_rust_repo_at`, `history_spread_shas`,
`add_diverged_backdrop`, `create_mixed_repo_at`,
`create_prune_real_repo_at`, `prune_fixture_state`); the public surface
now matches what the benches and CLI actually use. Doc references to
them are plain code spans rather than intra-doc links — rustdoc's
`private-intra-doc-links` lint rejects public docs linking private items
even under `--document-private-items`.

Checked and deliberately kept: `wt-perf trace` and `wt-perf invalidate`
(distinct documented workflows), the `TraceResult`/`CacheReport`
re-exports (public field types of `TraceEntry`/`Profile`), and the
`create_repo`/`create_repo_at` two-layer fixture API (both layers used).

Gate green: 4387 tests, clippy/fmt/doctests/rustdoc (`-Dwarnings`)
clean.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:05:27 -07:00
Maximilian Roos 18d2bef84b refactor(perf): canonicalize the benchmark / wt-perf system (#3403)
Implements the design proposal from this branch's first commit
(b22aced85, `design/simplify-bench-perf.md` — reviewable there; dropped
from the merge candidate per the `design/` convention). The perf
tooling's emission side was already canonical; this consolidates the
consumption side around one split: capture/harness work in `wt-perf`,
analysis in `worktrunk::trace` with `wt config state logs profile` as
its one CLI surface.

The seven design items:

1. **`wt-perf cache-check` deleted** — its output was the `cache` field
of `logs profile --format=json`. The tend statusline recipe now uses
`logs profile` (its old pipe also fed human-format stderr into a JSON
parser, so it's now correct as well as canonical).
2. **Timeline renderer moved into `worktrunk::trace`**
(`src/trace/timeline.rs`), sharing
`command_label`/`render_table`/`fmt_dur` with the profile so the two
views can't drift (they already had); drops wt-perf's `tabwriter` and
`insta` deps. Durations now render in the profile's fixed-point format.
3. **`Profile.by_context`** (BY CONTEXT table + JSON array):
per-worktree subprocess totals, the one analysis that previously
required trace_processor SQL. The SQL sections in `benches/CLAUDE.md`
and the `trace` module docs are replaced by a `logs profile` pointer;
Perfetto stays for visual critical-path work.
4. **One warm/cold bench runner** (`wt_perf::bench_wt`) owns the
`BatchSize::PerIteration` rationale once, replacing five inline copies.
All runs now assert child exit status (`benches/list.rs` previously
ignored it).
5. **Bench matrix pruned**: `real_repo` keeps only the 8-worktree
variants (scaling shape stays on synthetic `worktree_scaling`),
`cow_copy` deleted (benchmarked a hand-written serial copy against
production rayon — a settled choice), `remove_e2e/first_output` deleted
(duplicate of `first_output/remove`). Ends three gist series with this
PR as the single discontinuity point; saves roughly 15–20 min of the
~80-min daily bench run.
6. **wt-perf CLI tests moved in-package** (native
`CARGO_BIN_EXE_wt-perf`): the dummy `builds.rs` is gone and the nextest
setup script builds only `mock-stub`. Note `cargo test --test
integration` no longer runs these four tests; full-workspace runs still
do.
7. **`parse_config` returns a `SetupConfig` enum** covering `mixed-W-B`;
the two fixture builders share `init_bench_repo`.

**Merge with main (#3401):** the new prune benches and fixtures landed
mid-flight in exactly these files; the resolution routes them through
the consolidated API — `prune-M-U` is a `SetupConfig::Prune` variant,
the pair parsing is one shared `parse_pair`, and `prune-real` keeps its
own cache-managed path (no `--path`, self-repairing). `benches/prune.rs`
keeps its custom `iter_batched` arms deliberately: every variant asserts
the candidate count from stdout, which `bench_wt`'s shape doesn't
express — the exact carve-out the cache-handling docs describe.

Two review passes (correctness + subtraction) then folded in:
`CacheReport` trimmed to its same-context fields (the dropped six
duplicated `Profile` fields or counted cross-context noise the text
report never showed), `render_table` pads by display width (replacing an
ASCII-only invariant the new BY CONTEXT table violated), the timeline's
instant-event row gained test coverage, and Chrome args dropped the
redundant `duration_ms`.

**Decisions surfaced, not taken** (from the review passes): (a) `wt-perf
setup` could become `--persist`-only, dropping the interactive
Enter-to-cleanup path — a UX preference; (b) whether the daily benchmark
cron's gist time series justifies its remaining ~60 min/day is a
cost/value call. Also deferred: measuring whether `skeleton/cold` adds
signal over `skeleton/warm` before cutting it.

Gate green throughout (4374 tests); `clippy --features
shell-integration-tests` clean; `setup`/`timeline` (warm, cold,
chrome)/`logs profile` (text + JSON)/`trace` all driven end-to-end on a
scratch `mixed-4-8` repo.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 03:44:18 -07:00
Maximilian Roos 5e58827a28 bench(prune): rust-scale prune fixture, criterion benches, and removal trace spans (#3401)
Makes `wt step prune` / `wt remove` staging performance measurable and
reproducible. Users report prune taking many seconds while the synthetic
benches scan in ~150 ms; this adds the observability and fixtures that
reproduce the gap and attribute it. No `wt` runtime behavior changes —
the `src/` diff is ~29 lines of trace spans.

## What a reviewer needs

- **Trace spans** (`src/commands/step/prune.rs`,
`src/commands/process.rs`, `src/git/fsmonitor.rs`): `prune-gather` /
`prune-scan` / `prune-check:<ref>` / `prune-remove:<label>` plus
`internal-sweep` around `wt remove`'s end-of-command janitor. A
`prune-remove` span starts before the write-lock acquisition, so it
reads as "how long this removal stalled the run" (documented in
benches/CLAUDE.md).
- **Fixtures** (`tests/helpers/wt-perf/src/lib.rs`, the bulk of the
diff): `prune-M-U` — M squash-merged candidate pairs
(content-integrated, the post-PR-squash shape) against a
two-sided-diverged backdrop forked across history; `prune-real[-M-U]` —
the same shape on a rust-lang/rust clone, default 12+24 → 36 linked
worktrees. The real fixture is cache-managed under `target/bench-repos/`
(~15 GiB, minutes to build): `ensure_prune_real_repo` classifies it
Intact/Consumed/Broken on reuse, re-creates candidates consumed by a
live prune in ~1 min (round derived from the surviving squash commits,
no sidecar state), and heals invalidated worktree indexes.
- **Benchmarks** (`benches/prune.rs`): criterion groups `prune_e2e`
(dry-run cold/warm + live with per-iteration candidate re-creation) and
`prune_real_repo` (warm dry-run only). The real group is gated behind a
new `real-repo-benches` cargo feature so the nightly benchmarks workflow
— plain `cargo bench` on a hosted runner — never builds a fixture bigger
than the runner's disk and the actions cache cap.

## Measured (M-series Mac, in benches/CLAUDE.md)

Live prune on the rust-scale fixture: **~12 s wall** — a 5.4 s stat-cold
scan (36 `git status` at ~4.5 s each, absorbed by the rayon pool) plus
24 removals serialized under the scan write lock (~0.5–1.7 s per
worktree candidate). Warm re-scan: 0.25–0.8 s. This reproduces the
reported "prune takes seconds" experience and points optimization at
status cost and removal overlap, not the integration probes.

Rider fix picked up en route:
`docs.anthropic.com/en/docs/build-with-claude/claude-code` now 404s
(failing lychee on every push, including on main), so the llm-commits
installation link and the `wt config plugins` install-hint now point at
`code.claude.com/docs/en/setup`.

Also fixes a latent fixture bug: `history_spread_shas` divided by its
hardcoded 5000-commit cap, so on short synthetic histories every
"history-spread" fork silently collapsed to the tip.

## Testing

wt-perf unit tests cover the fixture lifecycle (state classification,
derived repair rounds, squash content-integration); every
`ensure_prune_real_repo` path (build, cached, repair, index-heal,
foreign cwd) was exercised end-to-end locally, as were both criterion
groups and the one-shot timelines. An integration test asserts the `wt
remove -vv` trace surfaces the fsmonitor sweep (span plus daemon count),
pinning the sweep's observability contract. The rust-scale numbers are
I/O-bound and ambient-load-sensitive — documented as shape, not
thresholds.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:35:28 -07:00
Maximilian Roos e9bedf5b03 fix(bench): restore worktree indexes after cache invalidation (#3359)
## Summary

Deleting a worktree's `.git/index` isn't a cold cache: git treats a
missing index as empty, so `git status` reports every tracked file as a
staged deletion — a different repo state, not a cold cache. That flips
any clean-worktree gate a benchmarked command exercises (e.g. `wt step
prune`'s removability check would silently drop every worktree candidate
against such a worktree; verified empirically on a `timeline --cold`
prune run).

Adds `wt_perf::restore_worktree_indexes`, which `git reset -q`s every
worktree back to a clean `git status` after `invalidate_caches_auto`,
and documents the trap in `benches/CLAUDE.md`.

`restore_worktree_indexes` has no callers on this branch yet — an
upcoming prune-benchmark branch is its first consumer.

## Test plan

- [x] `cargo build -p wt-perf` — clean, no warnings
- [x] `cargo clippy -p wt-perf --all-targets` — clean
- [x] `cargo run -- hook pre-merge --yes` — all pre-commit hooks, fmt,
clippy, full nextest suite (4309 passed), doctests, and docs pass

> _This was written by Claude Code on behalf of Maximilian Roos_

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 18:50:39 -07:00
Maximilian Roos 28c937dbbf bench(list): consolidate synthetic wt list benches into one full group (#3335)
Consolidates the narrow synthetic `wt list` benchmarks into a single
`full` group backed by one combined fixture, so one bench exercises the
whole surface (many worktrees AND many branches in varied states)
instead of several groups that each covered worktrees-only or
branches-only in a uniform state.

## What changed

- **New `full` group** (`benches/list.rs`) runs `create_mixed_repo(24,
120)` cold + warm: 24 worktrees and 120 branches in mixed
clean/dirty/staged working trees and merged/ahead/diverged branch
states, with branch divergence spread across history depth (the GH #461
cost driver). `full/warm` ≈ 147ms, `full/cold` ≈ 480ms locally.
- **Folded in:** the old `full` (worktrees-only — a duplicate of
`worktree_scaling` despite the name), `many_branches` (100 uniform
branches), and the warm-only `rerun_warm` seed. No coverage dropped:
`worktree_scaling` keeps the worktree-count scaling sweep, and the
combined fixture's branch side subsumes `many_branches`.
- **Kept standalone:** `divergent_branches` (the pure #461
deep-divergence stress) and `worktree_scaling` — both are cited in
`src/main.rs` as the `rayon_thread_count` tuning anchors, and together
they are the per-side regression trackers.
- **Fixture extension** (`create_mixed_repo` in
`tests/helpers/wt-perf`): added the deep-divergence shape it lacked —
deeper base history (120 → 200 commits), checkpoints every 5,
behind/diverged branches forking at points fanned across the full depth,
and multi-commit diverged chains. Signature unchanged, so `wt-perf setup
mixed-W-B` still works.
- **Attribution:** a `full` wall time can't be decomposed by side
(worktree- and branch-side git subprocesses overlap on the rayon pool),
so `benches/CLAUDE.md` documents the `wt-perf timeline` `args.context`
trace query (query #3) as the way to localize a regression.
- `run_benchmark` now takes `cold_cache: bool` instead of
`&BenchConfig`, decoupling it from the fixture type. Removed a stale
`timeout_effect` doc reference (no such group exists).

Benchmark-only change (plus the `wt-perf` test helper and
`benches/CLAUDE.md`); no production code. All six `list` groups compile
and run; clippy `-Dwarnings`, fmt, and the `wt-perf` tests are clean.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:01:13 -07:00
Maximilian Roos f045e0f449 perf(list): cut warm-cache wt list re-run ~36% by killing redundant per-row git forks (#3334)
## What

A warm re-run of `wt list` (where `.git/wt/cache/` is already populated)
is throughput / critical-path bound on per-worktree and per-branch git
subprocesses. Two of worktrunk's three cache layers — `Arc<RepoCache>`
and the process-global path/merge-base/commit-tree `DashMap`s — live in
process memory and don't survive across invocations, so every re-run
re-forks work that's content-addressed or already in hand. The "~1 ms
recompute, not worth a disk file" assumption behind keeping merge-base
and commit→tree in memory is false at scale on macOS (10–25 ms per fork
× dozens of rows).

This eliminates three classes of those forks. On a 24-worktree /
120-branch mixed-state fixture, subprocess work drops **3629 ms → 1013
ms**; the criterion `rerun_warm` benchmark goes **212 → 135 ms median**
(−36%, p<0.05). What remains is the irreducible working-tree floor —
per-worktree `git status`, and `add -A`/`diff`/`write-tree` for dirty
worktrees — which depends on live working-tree state and genuinely can't
be cached.

## The three changes

1. **`%T` primes the commit→tree cache** (`diff.rs`). The pre-skeleton
`git log --no-walk` batch already resolves each commit for `%ct`; adding
`%T` rides along for free and primes `commit_tree`, so the per-row
`CommittedTreesMatch` / `WouldMergeAdd` tree lookups never fork `git
rev-parse <sha>^{tree}`.
2. **Persist merge-base** (`sha_cache.rs`, `diff.rs`). New
content-addressed `merge-base/` sha_cache kind (in-memory front over
disk back) plus a `sha1 == sha2` short-circuit. The `AheadBehind` orphan
check forked `git merge-base` per row even when ahead/behind counts were
already cache-warm; now it's a file read on re-runs.
3. **Prime worktree root/git-dir** (`mod.rs`, `collect/mod.rs`). `git
worktree list` already gives every worktree's path; seeding
`WORKTREE_ROOTS`/`GIT_DIRS` from it (git-dir read from each `.git`
entry, mirroring `prewarm_rev_parse`'s canonicalization) eliminates the
per-worktree `git rev-parse --show-toplevel` / `--git-dir` forks. Runs
post-skeleton — its values are consumed only by the worker pool, so it
stays off the skeleton critical path and is skipped under
`WORKTRUNK_SKELETON_ONLY` (measured: skeleton time unchanged at 24.4
ms).

All three are behavior-preserving: caches are content-addressed and
never stale, and any value that can't be derived cleanly falls back to
the original subprocess.

## Benchmark

Adds `cargo bench --bench list rerun_warm` (24 worktrees + 120 branches
in varied states: clean / unstaged / staged+untracked worktrees; merged
/ ahead / diverged / identical branches), a reusable
`wt_perf::create_mixed_repo` fixture, and a `wt-perf setup mixed-W-B`
config. The existing list benches are warm-capable but each cover
worktrees-only or branches-only in a single uniform state. A
`TODO(bench-full-combined)` marks consolidating these into one cold+warm
"full" scenario with per-feature trace attribution.

## Reviewer orientation

- Cache mechanics: `src/git/repository/sha_cache.rs` (new kind) and the
`# Caching` docstring in `src/git/repository/mod.rs` (updated to
document the in-memory-front-over-disk-back decision and correct the
stale cost premise).
- The list-collect path: `src/commands/list/collect/mod.rs` module
docstring (caching table + `%T` fork doc updated).
- Tests: cache priming, merge-base roundtrip (incl. orphan), and a
prime-vs-subprocess equivalence test that pins the derived git-dir
against the real `git rev-parse` across main + linked worktrees.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:25:02 -07:00
Maximilian Roos c1c76b150d refactor(trace): decouple human trace.log from machine trace.jsonl (#3297)
At `-vv`, worktrunk's `trace.log` carried machine-parseable `[wt-trace]
ts=… tid=… seq=… cmd="git status" dur_us=12300 ok=true` lines
*alongside* a `$ git status [ctx]` start echo — so every command
appeared twice in two inconsistent renderings, and the human file was
cluttered with `key=value` machine fields. The root cause: `trace.log`
was doing double duty as both the human artifact and the machine-parsed
source, even though `trace.jsonl` (added in #3232) already carries the
same fields losslessly and nothing read it as input.

This decouples the two. `trace.log` + stderr become purely human;
`trace.jsonl` becomes the sole machine format.

```text
# BEFORE — two inconsistent renderings of one command
[h] $ git rev-parse --show-toplevel [worktrunk.rev-parse-dedup]
[h] [wt-trace] ts=11593 tid=8 seq=5 context=worktrunk.rev-parse-dedup cmd="git rev-parse --show-toplevel" dur_us=6101 ok=true

# AFTER — start ($) and finish (✓) pair, one `cmd [ctx]` rendering, no clutter
[h] $ git rev-parse --show-toplevel [worktrunk.rev-parse-dedup]
[h] ✓ git rev-parse --show-toplevel [worktrunk.rev-parse-dedup]  6.1ms
```

Commands render `$ …` (start) → `✓`/`✗ … dur` (finish), in-process spans
`◷ name dur`, milestones `· event` — the leading glyph names the line
type at a glance.

## What a reviewer needs

- **`src/trace/parse.rs`** — rewritten to parse `trace.jsonl` (one JSON
object per line) via a `kind`-dispatch
(`cmd_completed`/`cmd_errored`/`instant`/`span`), skipping non-records
(`{"message":…}` logs, `$ cmd` echoes, non-JSON). Cleaner than inferring
kind from which fields are present.
- **`src/logging.rs`** — `format_wt_trace` now emits the human line;
`style_stderr_line` bolds the command for `$`/`✓`/`✗` lines so a start
and its finish read as a pair. The machine fields (`ts`/`tid`/`seq`)
live only in `trace.jsonl` (via the separate JSON visitor).
- **Consumers repointed at `trace.jsonl`**: `wt config state logs
profile`, the `wt-perf` helper (`timeline` now runs `wt -vv` and reads
the file, located via `git rev-parse --git-common-dir`), and the
`diagnostic.md` profile section (while still inlining the human
`trace.log`).
- **Docs** — help text, the FAQ file inventory (added `trace.jsonl`),
and the `[wt-trace]`-grammar descriptions across
`emit.rs`/`log_files.rs`/`benches/CLAUDE.md` updated.

One behavior note: `wt-perf timeline` now writes trace files to the
repo's `.git/wt/logs/` (a side effect of `-vv`) where the old
`RUST_LOG=debug` path didn't — expected for a `-vv`-based perf helper.

## Testing

Well-covered: parser (14 unit tests), renderer + stderr styling (8),
`logs profile`/`diagnostic`/`switch`/`completion` integration tests
migrated to JSON fixtures, plus a `wt_target_dir` unit test for the
wt-perf `-C` resolution. Verified `logs profile`, `wt-perf timeline`
(text + `--chrome`), and `cache-check` end-to-end on a real `-vv`
capture. An independent review pass found no correctness issues; its
findings (a missed doc, two wt-perf robustness fixes) are folded in.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:59:31 -07:00
Maximilian Roos c70eedf152 feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows

The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.

This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.

With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.

### Where to look

- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.

### Testing

Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
Maximilian Roos d9b8eb12e4 fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).

## Fixes

- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.

## Scope

Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.

## Coverage

`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.

## Testing

`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
Maximilian Roos 50a30208cd bench(alias): use BatchSize::PerIteration for cold-cache variants (#2731)
## Summary

Follow-up to #2728. `benches/alias.rs` was the last
`iter_batched(invalidate_caches_auto, …, BatchSize::SmallInput)` site in
the bench suite. Under `SmallInput`, criterion calls the setup closure
once per batch and times the routines back-to-back inside one timing
window — so only iter 1 per batch is actually cold, and iters 2-N hit
the `worktrunk.default-branch` git-config entry that
`build_hook_context` wrote on iter 1 (default-branch resolution is ~17ms
on a synthetic repo per `benches/CLAUDE.md`). `BatchSize::PerIteration`
runs `setup → time(routine)` per iter, so every measurement is genuinely
cold.

## Measurement

`dispatch/cold/1` (stub alias, 1 worktree):

| | Before | After |
|---|---|---|
| Median | 23.14 ms | 23.94 ms |
| Range | [22.34, 24.01] | [22.91, 25.03] |

Median +6.9% (p=0.05, "Change within noise threshold"). The correction
is small — alias dispatch is cheap (~23ms) and only the default-branch
cache is being repopulated, so each warm iter saved ~1ms — but the
direction is right and it brings the bench in line with the rest of the
suite (#2728) and the `benches/CLAUDE.md` "Cache Handling" guidance.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` — 3684 tests pass, 0 failed
- [x] `dispatch/cold/1` run before+after on this branch with
`--save-baseline` / `--baseline`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:42:24 -07:00
Maximilian Roos d7c8428e16 bench: use BatchSize::PerIteration for cold-cache variants (#2728)
## Summary

Cold benchmark variants in `list.rs`, `remove.rs`, and
`time_to_first_output.rs` were passing `BatchSize::SmallInput` to
`iter_batched`. Under `SmallInput`, criterion calls the setup closure
once per batch up front and then runs the routines back-to-back inside a
single timing window — so when setup is `invalidate_caches_auto`, only
iter 1 per batch is actually cold and iters 2-N read from the cache the
previous routine just populated. The reported "cold" medians were
warm-biased averages.

`BatchSize::PerIteration` switches to `setup → time(routine)` per iter,
so every measured iter is genuinely cold. The setup is far cheaper than
a `wt` subprocess, so per-iter `Instant::now` overhead doesn't dominate.
Codex flagged this on the sibling `picker_preview` bench (#2721); that
PR landed `PerIteration` for its cold path, and this aligns the rest of
the bench suite.

## Measured corrections

| Variant | Before median | After median | Spread (before → after) |
Median Δ |
|---|---|---|---|---|
| `list full/cold/8` | 113.5 ms | 107.1 ms | 16.0 → 3.9 ms | −5.7%
(n.s., p=0.14) |
| `remove_e2e/first_output` | 48.2 ms | 86.4 ms | 2.6 → 17.9 ms |
**+44.4%** (p<0.001) |
| `first_output/remove` | 50.5 ms | 69.6 ms | 2.4 → 0.65 ms | **+23.0%**
(p<0.001) |

`remove_e2e/first_output` is the starkest correction —
`compute_integration_lazy` writes `is-ancestor` / `has-added-changes` /
`merge-add-probe` entries, so warm-bias was substantial.
`first_output/remove` is the cleanest demonstration of variance
tightening (3.7× tighter spread). `list full/cold/8` doesn't move the
median significantly but the upper-tail outliers that warm-bias was
inflating disappear.

## Scope

Only the four `iter_batched(… invalidate_caches_auto, …,
BatchSize::SmallInput)` call sites are switched:

- `benches/list.rs` `run_benchmark` (covers `skeleton`, `full`,
`worktree_scaling`, `many_branches`, `divergent_branches` cold variants)
- `benches/list.rs` `bench_real_repo` cold path
- `benches/remove.rs` `first_output`
- `benches/time_to_first_output.rs` `remove`

Other `BatchSize::SmallInput` sites in `remove.rs` (`no_hooks`,
`with_hooks`) use `recreate_worktree` as setup — that doesn't invalidate
a cache the routine repopulates, so they stay as-is.

`benches/CLAUDE.md` "Cache Handling" gets a paragraph so future bench
authors reach for `PerIteration` when the setup invalidates a cache the
routine repopulates.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` — 3667 tests pass, 0 failed
- [x] Each cold variant run before+after on this branch with
`--save-baseline` / `--baseline`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:50:27 -07:00
Maximilian Roos 80ee2cb365 bench: measure wt switch picker preview pre-compute workload (#2721)
## Summary

- Adds `picker_preview` benchmark group measuring "process spawn → all
preview tasks drained" for `wt switch`'s interactive picker.
- Introduces `WORKTRUNK_PREVIEW_BENCH=1`, an early-exit gate inside
`handle_picker` that runs the full prelude (collect, speculative spawn,
skeleton, initial + deferred precompute, `orchestrator.wait_for_idle()`)
and returns before skim launches or any JSON / stderr I/O. Shares the
dry-run path; behavior with the env var unset is unchanged.
- Closes the coverage gap behind #2662 / #2683 / #2685 / #2704, which
were tuned against `wt list` as a proxy because no direct picker
measurement existed.

## Why this measurement

Picker submits one preview-compute task per row to the global rayon
pool. The user-visible quantity to optimize is the responsiveness window
between picker launch and "all previews ready" (j/k navigation hits
cached content). Option 1 from the task — headless wall clock to drain —
is the cleanest measurable proxy and avoids the PTY route, which hits
the documented nextest/SIGTTOU pain on `shell-integration-tests`.
PTY-driven first-interactive-ready can be a follow-up.

## Variants

- `picker_preview/warm/typical-8`
- `picker_preview/cold/typical-8`

Cold uses `BatchSize::PerIteration` (not `SmallInput`): `SmallInput`
calls `setup` for an entire batch up front and then runs timed routines
back-to-back, so only the first iter in each batch is genuinely cold —
the rest hit a freshly populated `.git/wt/cache/`. `PerIteration`
invalidates immediately before every measured iteration; setup is far
cheaper than `wt switch`, so per-iter `Instant::now` doesn't dominate.

`sample_size(10)` + `measurement_time(35s)` per #2685's lead — slow
benches don't benefit from the default 30 samples.

`cfg(unix)`-gated with a no-op `main` on Windows; the picker is
Unix-only and `wt switch` (no args) hits the unavailable path before the
env var is consulted.

## Sample run

```
picker_preview/warm/typical-8   time:   [185.62 ms 191.72 ms 200.77 ms]
picker_preview/cold/typical-8   time:   [209.34 ms 226.23 ms 239.29 ms]
```

## Test plan

- [x] `cargo bench --bench picker_preview` runs cleanly on both variants
- [x] `cargo run -- hook pre-merge --yes` — 3667 tests pass
- [x] New `test_picker_preview_bench_produces_no_output` asserts
`WORKTRUNK_PREVIEW_BENCH=1` keeps stdout/stderr empty (covers the
env-gated branch, locks the no-I/O contract)
- [x] Smoke test: `wt switch` with `WORKTRUNK_PREVIEW_BENCH` unset still
hits the TTY error path (user-visible behavior unchanged)
- [x] Smoke test: `WORKTRUNK_PICKER_DRY_RUN=1` still emits the cache
JSON dump (regression check)
- [x] `/review-codex` pass clean after iterating on three findings
(packed-refs fix already on `main` via #2697 once branch was rebased;
`BatchSize::PerIteration` for true per-iter invalidation; `cfg(unix)`
gate for Windows)

> _This was written by Claude Code on behalf of Maximilian Roos_

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:55:22 -07:00
Worktrunk Bot de3ef902da fix(bench): stop deleting packed-refs in invalidate_caches_auto (#2697) 2026-05-11 02:47:36 -07:00
Maximilian Roos 706fb5a1d9 fix(bench): trim real_repo sampling; restore sample_size(10) criterion floor (#2695)
## Summary

Two changes to `benches/list.rs`, no production code.

**1. Trim `bench_real_repo` sampling.** It inherited the
`criterion_group` default `sample_size(30)` / `measurement_time(15s)`.
On rust-lang/rust `wt list` runs ~2s warm (dominated by one deep `git
for-each-ref %(ahead-behind:main)` walk) and several times that for
`cold/8`, where every iteration also rebuilds eight 59k-entry git
indexes via `git status`. At >1s/iter Criterion can't fit 30 samples in
15s, so it just runs 30 single-iteration samples per variant — the group
as a whole runs ~15–20 min, dominated by `real_repo/cold/{4,8}`. Set
`sample_size(10)` (criterion 0.8's hard minimum) +
`measurement_time(20s)`, mirroring what #2685 did for
`bench_real_repo_many_branches`. The expensive cold variants drop from
30 to 10 iterations → group's measured time roughly thirds (~6–8 min).
No change to what `wt list` runs or measures.

**2. Fix a latent panic in `bench_real_repo_many_branches`.** #2685 set
it to `sample_size(5)`, but criterion 0.8's `sample_size` does
`assert!(n >= 10)` (`benchmark_group.rs:97`), so `cargo bench --bench
list` currently **panics on `main` during group registration** — for any
filter, before any benchmark runs. Restored to `sample_size(10)`. (This
was also a prerequisite for running `bench_real_repo` to validate change
#1.)

## Why not sparse-checkout the rust clone

A sparse checkout would make the per-variant setup near-instant and `git
status` / `git diff --shortstat HEAD` on the rust worktrees ~10ms
instead of ~1.8s / ~0.8s — but those costs are exactly what the `cold`
(cold 59k-entry index rebuild) and `warm` (working-tree scan over 59k
files) variants exist to measure. Sparse-checkout would turn "cold index
on a huge repo" into "cold index on ~20 files", erasing the documented
`cold ~4×` insight. The mechanism works; it's the wrong tradeoff.
Sharing one rust workspace across the warm/cold pair was also considered
and dropped — after the sampling trim, setup is a small slice (~1.5–2
min of ~7 min), and sharing would require up to ~10 GB of simultaneous
rust checkouts (CI disk risk) for a marginal gain.

## Before / after

`real_repo/warm/1` (rust-lang/rust clone, one worktree): median
**1.809s** (30 samples) → **1.794s** (10 samples). Criterion's
regression check on the after run: `change: [−23.1% −7.0% +10.8%] (p =
0.59)` → "No change in performance detected." The cold variants — the
bulk of the group — drop 3× by arithmetic (30→10 single iterations of
the same operation).

## Test plan

- [x] `cargo run -- hook pre-merge --yes` — 3645 tests pass, clippy +
fmt + pre-commit hooks pass
- [x] `cargo bench --bench list -- 'real_repo/warm/1'` — runs cleanly,
`sample_size(10)` accepted ("Collecting 10 samples"), result above
- [x] `cargo clippy --benches -- -D warnings`, `cargo check --benches`,
`cargo fmt --check`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:52:18 -07:00
Maximilian Roos df35af933b perf(bench): cheaper cargo bench --bench list (lazy fixtures + lighter real-repo sampling) (#2685)
## Summary

Two changes that make `cargo bench --bench list` cheaper to run,
especially when filtered. No production code, no change to what the
benches measure on a full run.

**1. Lazy fixture creation.** The synthetic `wt list` bench groups built
their fixture repos *eagerly* at group-registration time:
`bench_skeleton`, `bench_full`, `bench_worktree_scaling`,
`bench_many_branches`, and `bench_divergent_branches` looped over their
`(worktrees, cold)` variants and called `create_repo` for every one —
before Criterion applied its positional `FILTER`. So `cargo bench
--bench list skeleton/warm/8` still paid to build all 6 skeleton
fixtures plus all 6 `full`, all 6 `worktree_scaling`, both
`many_branches` (100 branches each), and both `divergent_branches` (200
branches × 20 commits each) before measuring one benchmark.

This moves `create_repo` (and the `setup_fake_remote` / `git status`
prep) inside the `bench_with_input` / `bench_function` closure — exactly
how `bench_real_repo` and `bench_real_repo_many_branches` already work.
Criterion invokes that closure once per benchmark and only for
benchmarks the filter selects, so an excluded variant now creates no
fixture at all.

*Hermeticity is unchanged*: each benchmark still gets its own fresh
`TempDir` + repo via `create_repo`, created exactly once, before
`b.iter` (so never inside the measured region), alive for the whole
measurement loop, dropped right after. Marginally cleaner if anything —
a filtered-out benchmark now produces zero state instead of a stray
TempDir.

**2. Trim `real_repo_many_branches` sampling.** That group ran 10
samples against a 60s `measurement_time` — but at ~3.7s per `wt list
--branches` iteration on rust-lang/rust, Criterion filled the budget
with ~2 iterations per sample (~74s of measured time per
`bench_function`). The warm-path variance is dominated by the slowest
single subprocess (a deep `git merge-base` walking history), not
measurement noise, so 5 samples already pins the signal. Cut to
`sample_size(5)` + `measurement_time(20s)` — ≈ one iteration per sample
(~18s), so the group's two bench_functions drop from ~3.6min to ~1.7min
of measured time. (Cutting `sample_size` alone would have saved nothing
— Criterion sizes iterations-per-sample to fill `measurement_time`.)

## Effect

- `cargo bench --bench list <non-matching-filter>` → ~0.2s (measured),
down from minutes of fixture churn
- `cargo bench --bench list skeleton/warm/1` → 1 fixture built, was 6
- `real_repo_many_branches` group → ~1.7min measured, was ~3.6min
- Full unfiltered `cargo bench --bench list` → fixture count unchanged;
real-repo group ~2min faster

## Test plan

- [x] `cargo check --benches`
- [x] `cargo bench --bench list -- --test skeleton` — all 6 variants
pass
- [x] `cargo bench --bench list -- --test <non-matching>` — 0.23s, no
fixtures created
- [x] `cargo run -- hook pre-merge --yes` — 3585 tests pass, lints pass

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:51:33 -07:00
Maximilian Roos db1beb61b8 Add wt-perf timeline subcommand for trace capture & rendering (#2558)
## Summary

Adds `wt-perf timeline` to standardize how we capture and read traces
for one-off `wt` invocations (alias dispatch, `wt list`, etc.). Replaces
the prior `RUST_LOG=debug wt … 2>&1 | wt-perf trace > trace.json` dance
with a single command that runs `wt`, captures stderr, parses
`[wt-trace]` records, and either pretty-prints a sorted text timeline or
emits Chrome Trace Format JSON for Perfetto.

The text mode is the path I expect to use most — it's a column-aligned
table sorted by start time, with subprocess totals and an
externally-measured wall (`spawn → wait`) so the unobserved
prelude/epilogue is visible:

```
ts(ms)  dur       tid  kind  name
0.000   11µs      1    span  init_logging
0.043   3.979ms   1    span  prewarm
0.058   3.882ms   1    cmd   git rev-parse --git-common-dir … HEAD [wt-perf-typical-1]
…

3 subprocesses totaling 11.315ms (slowest: 3.882ms git rev-parse … HEAD)
traced: 14.838ms (first → last [wt-trace] record)
wall:   19.367ms (spawn → wait; +4.529ms untraced prelude/epilogue)
```

## What's in the diff

- **`tests/helpers/wt-perf/src/main.rs`** — new `Timeline` subcommand.
Resolves `wt` as a sibling of `current_exe()` (Windows-aware via
`EXE_SUFFIX`), optionally invalidates caches with `--cold [--repo
PATH]`, runs `wt` with `RUST_LOG=debug`, captures stderr, and renders.
Uses `tabwriter` for column alignment, `Duration`'s std `Debug` for
compact unit display, and `insta` for inline snapshot tests.
- **`tests/helpers/wt-perf/Cargo.toml`** — new deps: `tabwriter`
(release), `insta` (dev).
- **`CLAUDE.md`** — fixes stale `--skip` syntax in the Benchmarks
section (Criterion takes a positional FILTER, per #2547), replaces the
non-existent `bench_list_by_worktree_count` example, adds a Traces
section.
- **`benches/CLAUDE.md`** — leads "Generating traces" with `wt-perf
timeline`; the manual stderr-redirect pipeline is now framed as the path
for traces from a log already on disk.
- **`src/trace/mod.rs`** module doc — same usage block update so library
doc and CLAUDE.md don't drift.
- **`wt-perf setup`** printed suggestion — hints at `wt-perf timeline`
instead of the manual stderr redirect.

`wt-perf trace` (stdin/file → Chrome JSON) and `wt-perf cache-check` are
unchanged — they still serve the case where you have a captured log and
want to convert or analyze it without re-running.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` (3432 tests, lints, doctests)
passes locally
- [x] `wt-perf timeline -- -C \$REPO stub` end-to-end on a typical-1
fixture
- [x] `wt-perf timeline --cold --repo \$REPO -- -C \$REPO stub`
invalidates and reruns
- [x] `wt-perf timeline --chrome -- -C \$REPO stub` produces valid JSON
(loaded into `python -m json.tool`)
- [x] `cargo test -p wt-perf --bin wt-perf` — 2 inline insta snapshots
pass
- [ ] CI green

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 13:01:26 -07:00
Maximilian Roos 05514ef44c docs(bench): replace --skip with positional FILTER (#2547)
Criterion 0.8 (the maintained criterion-rs fork — already the latest)
has no `--skip` flag; only positional substring `[FILTER]` and
`--exact`. Four invocations in `benches/CLAUDE.md` and the
`benches/alias.rs` header comment used `--skip`, which fails with
`unexpected argument '--skip' found`.

Rewritten to use positional filters with concrete worked examples, plus
a short note that Criterion has no exclusion flag and that benchmark IDs
follow `<group>/<label>/<param>`. For the alias bench, `dispatch/warm`
and `dispatch/cold` replace the previous `--skip cold` / `--skip
with_vars` examples.

Verified `cargo bench --bench alias dispatch/warm -- --measurement-time
5 --sample-size 10` runs exactly the two stub-warm variants.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-02 22:51:33 -07:00
Maximilian Roos c710147c8c fix(bench): unbreak wt-perf invalidate; consolidate env-isolation helpers (#2540)
## Summary

Two bugs in `wt_perf::invalidate_caches_auto`, plus consolidation of the
env-isolation helpers shared between tests and benches.

## Bugs fixed

**(1) Silent unset under inherited `GIT_DIR`.** `invalidate_caches_auto`
shelled to `git config --unset worktrunk.default-branch` via
`worktrunk::shell_exec::Cmd`. `Cmd::apply_common_settings` re-injects
normalized inherited `GIT_*` path vars resolved against the wt-perf
process's startup cwd (issue #1914 — correct for `wt`-from-git-alias
where parent and child target the same repo). For wt-perf, which
operates on an explicit, possibly-unrelated repo path, the override
redirected the unset to the wrong git dir; the resulting non-zero exit
was swallowed by `let _ =`, and the cache stayed put.

Reproducer:
```bash
cd /tmp && GIT_DIR=.git target/release/wt-perf invalidate /tmp/wt-prof-test
# previously: silently leaves worktrunk.default-branch in place
# now: actually unsets it
```

**(2) Linked worktrees never cleared.** `invalidate_caches_auto`
resolved `<repo_path>/.git` naively. When `repo_path` is a *linked*
worktree, `.git` is a file (gitdir-pointer), not a directory, so every
`fs::remove_*` call no-op'd silently — `.git/wt/cache/`, `.git/index`,
`packed-refs`, commit-graph all survived. Existing bench callers all
pass the main worktree, so no current bench was affected, but the
function's contract was broken for linked-worktree inputs.

## Fixes

- wt-perf no longer routes through `worktrunk::shell_exec::Cmd`. All
`git` subprocess work uses `std::process::Command` directly via a small
`git_command()` helper that delegates to
`worktrunk::testing::configure_git_cmd`. The inherited-`GIT_*`
normalization (which was the root of bug 1) doesn't apply to plain
`Command`.
- `invalidate_caches_auto` resolves the git common dir from the
filesystem — reads `.git` as dir-or-pointer, strips `worktrees/<name>`
for linked worktrees — so the cache root is correct regardless of which
worktree of a repo is named.
- The `let _ =` on the unset is gone; non-success/non-5 exits print a
one-line `eprintln!` so a future regression can't hide the same way.

## Consolidation

Touched four near-duplicate env-isolation helpers
(`configure_cli_command`, `configure_git_cmd`, `configure_git_env`,
`wt_perf::isolate_cmd`). The shared core is now in `worktrunk::testing`:

- `pub fn isolate_subprocess_env(cmd, user_config: Option<&Path>)` —
strips host context (`GIT_*`, `WORKTRUNK_*`, `NO_COLOR`, `SHELL`,
`PSModulePath`) and sets baseline `WORKTRUNK_*_PATH`. Used by
`configure_cli_command` (test-side, layers on test determinism) and
every bench (no extra layer — benches want realism).
- `pub fn scrub_git_path_vars(cmd)` — env_remove the five `GIT_*`
repo-discovery vars. Called defensively from
`configure_git_cmd`/`configure_git_env` so test code that spawns `git`
outside a `wt`-parent isn't vulnerable to the same redirect.
- `wt_perf::isolate_cmd` deleted. Five bench files now import
`worktrunk::testing::isolate_subprocess_env` directly.
- Path strings unified: `/nonexistent/wt/{config,approvals}.toml` and
`/etc/xdg/worktrunk/config.toml` (preserved as the real XDG path). 47
snapshots regenerated.

## Test plan

- [x] `cargo clippy --all-targets --workspace` clean
- [x] 1072 + 607 lib unit tests pass
- [x] 1589 integration tests pass (vs. baseline's 3 pre-existing
failures, which the snapshot regen also resolved)
- [x] All 5 benches compile (`cargo bench --no-run`)
- [x] Bug 1 (GIT_DIR redirect): unset succeeds
- [x] Bug 2 (linked worktree): cache and config both cleared from any
worktree path
- [x] Vanilla path: still works

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:24:21 -07:00
Maximilian Roos 528d702e6b perf(bench): add with_vars alias variant to dispatch group (#2534)
## Summary

- Pairs with the existing stub variant to bracket the dispatch curve.
Body references vars that drive the expensive paths in
`build_hook_context` — `commit` / `short_commit` (rev-parse fork),
`default_branch` (cold detection), `primary_worktree_path` (lookup).
- On main today, `build_hook_context` computes every var unconditionally
— so with_vars tracks stub within noise. The variant becomes informative
once a filter scopes context build to referenced vars only, at which
point stub drops to a post-filter floor and with_vars holds near today's
uniform cost, making the filter's delta visible.
- Existing stub labels (`dispatch/{warm,cold}/{1,100}`) are unchanged —
nightly bench gist stays continuous. New variants land under
`dispatch/with_vars/{warm,cold}/{1,100}`.
- `upstream` is omitted because the bench repo has no remote; adding
`setup_fake_remote` would change what the stub variant measures and
break label continuity.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` — green (3425 tests)
- [x] Quick run of with_vars/warm/{1,100}: ~33–35ms (in line with stub
measurements)

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-02 13:22:11 -07:00
Maximilian Roos ffc8abe82a docs(network): codify local-first network access policy (#2536)
## Summary

- Adds a **Network Access** subsection to `CLAUDE.md` "Command Execution
Principles": worktrunk is local-first; the only fall-through-to-the-wire
helper is the first `Repository::default_branch()` call per repo. A TTL
cache does not authorize background polling.
- Updates the `default_branch()` doc comment to state the contract
explicitly — it's the sole "look it up if missing" helper allowed to hit
the wire.
- Notes in `benches/CLAUDE.md` that `setup_fake_remote` keeps every
cold-cache benchmark on the local `<r>/HEAD` fallback (~17 ms) rather
than the real `git ls-remote` path (100 ms–2 s in the wild).

The policy makes the rule visible before someone adds the next "lookup"
path that silently walks out to the wire — alias dispatch, hook context
build, or `wt statusline` triggering CI status per prompt are the
existing instances of the failure mode (left as separate follow-ups, not
in this PR).

## Test plan

- [x] Docs-only — no behaviour change, no test impact

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:17:37 -07:00
Maximilian Roos b27dc90777 refactor(bench): rename alias bench's NOOP_CONFIG → STUB_CONFIG (#2533)
## Summary

- `noop` is misleading — the alias still forks `/bin/sh -c 'echo
hello'`, which is the dominant cost the bench measures.
- `stub` accurately conveys "minimal alias body, parent-side dispatch
dominates" without claiming the dispatch itself is free.
- Bench output labels (`dispatch/warm/1`, `dispatch/cold/100`, …) are
unchanged — the rename is internal const + alias name + header comment.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` — green (3425 tests pass, all
lints pass)
- [x] `cargo check --benches` — clean

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-02 12:38:09 -07:00
Douglas Soares de Andrade c33cb8c665 Fix copy-ignored symlink destination boundary (#2501)
## Summary

Fixes a destination-boundary issue in `wt step copy-ignored`.

Previously, a linked worktree could contain a symlinked destination
directory
such as `target -> /tmp/outside`. When `copy-ignored` copied ignored
files from
another worktree, it would create nested directories and files through
that
symlink, writing outside the destination worktree. `--force` made the
same
boundary issue more risky because it could overwrite outside files.

This PR adds guarded copy entry points for `copy-ignored` that resolve
the
destination ancestry with `canonicalize_with_parents` and reject paths
whose
resolved parent chain escapes the destination worktree root. The guard
checks
parent ancestry rather than the final leaf so existing leaf-symlink
behavior
stays intact: without `--force` leaf symlinks are skipped, and with
`--force`
the symlink itself is replaced.

## Changes

- Add `copy_leaf_within_root` and `copy_dir_recursive_within_root`.
- Route `wt step copy-ignored` through the guarded copy functions.
- Add Unix regression tests for symlinked destination directories,
including
  the `--force` case.

## Tests

- `cargo fmt --all --check`
- `cargo clippy --all-targets --all-features -- -D warnings`
- `cargo test --lib copy`
- `cargo test --test integration step_copy_ignored`

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <worktrunk-bot@users.noreply.github.com>
2026-04-30 17:21:40 -07:00
Maximilian Roos 810cdc6636 feat(remove): add TTY progress spinner to --foreground (#2420)
## Summary

Adds a stderr spinner (`⠼ Removing 7,272 files · 64.5 MiB`) to `wt
remove --foreground`, driven by the same machinery #2413 introduced for
`wt step copy-ignored`. Large worktrees (e.g. a fat `node_modules`) no
longer hang with no feedback while the trash directory is unlinked.

## Approach

Generalizes the spinner from copy-specific to verb-parameterized:

- `src/copy_progress.rs` → `src/progress.rs`; `CopyProgress` →
`Progress`; `start(verb)` takes `"Copying"` or `"Removing"`; method
renamed `file_copied` → `record`. The TTY gate, 300ms startup delay,
IEC-byte formatting, and Drop-based line clearing all carry over
unchanged. A private `Progress::enabled(verb)` ctor is shared between
`start()` (TTY-gated) and the test code that needs an enabled instance
without a real terminal.
- New `src/remove_dir.rs` — `remove_dir_with_progress(path, &Progress)`
walks the tree iteratively, unlinks files on a dedicated 4-thread
`REMOVE_POOL` (matching `copy::COPY_POOL`), then `rmdir`s deepest-first.
Returns `(files, bytes)` so the post-op summary can match the spinner.
Best-effort throughout — read/unlink/rmdir errors are silently skipped
so the caller can always report the count of leaves we did manage to
remove (the worktree is already pruned at this point, a stuck trash
entry is recoverable).
- `src/output/handlers.rs` — `cleanup_staged_with_progress` helper gates
on `verbosity() >= 1`. Plumbed into both foreground branches (named +
detached HEAD). `print_message` now takes `Option<(usize, u64)>`;
background passes `None`.
- The foreground success line gains a gray `(N files · X MiB)` suffix
using `format_stats_paren`, matching the spinner's units (per the
user-output skill's "stats parentheses" convention).

`remove_worktree_with_cleanup`, the background path, and the picker flow
are intentionally untouched.

## Snapshot stability

The byte total walks the renamed worktree's `.git` pointer file, whose
content is the gitdir's absolute path — so the byte count is sensitive
to the temp-dir prefix (macOS `/var/folders/...` vs Linux `/tmp/...` vs
Windows). An insta filter normalizes the byte value to `[BYTES]` inside
the `(N files · X UNIT)` paren shape; the deterministic copy-ignored
summary (`Copied N files · X B` — no surrounding parens) stays asserted
unchanged.

## Verification

- Unit tests in `progress` and `remove_dir` modules (including a
permission-skip test for the read_dir-fails path)
- Integration snapshots updated to include the new gray suffix
- `cargo run -- hook pre-merge --yes` passes locally
- Manual TTY smoke test under tmux: spinner renders, ticks, gets cleared
before the summary; success line shows `(10,001 files · [BYTES] KiB)`
for a 10k-file worktree
- Manual pipe smoke test: no spinner frames, summary still shows the
counts

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 21:00:38 -07:00
Maximilian Roos da349b30d8 feat(step-copy-ignored): add TTY progress spinner (#2413)
Large copies of `node_modules`, `build/`, or `target/` ran silently — if
the walk or the copy was slow, users couldn't tell whether `wt step
copy-ignored` was making progress or hung. Adds a stderr-only spinner:
`⠼ Copying 7,272 files · 64.5 MiB`, updated in place at ~10Hz, only
while a real TTY is on the other end. The post-copy summary repeats the
same units (`✓ Copied 7,272 files · 64.5 MiB`) so the spinner's
information isn't dropped on completion.

Gate: stderr-is-TTY, `verbosity == 0` (verbose mode already lists every
entry), and not `--dry-run`. A 300ms startup delay keeps sub-second
copies silent — short copies never render a line at all. Non-TTY output
is byte-for-byte unchanged apart from the new byte total in the summary.

### Approach

- New `src/copy_progress.rs`: `CopyProgress` is `Option<Inner>` —
disabled is `None`, enabled holds an `Arc<Shared>` of atomic counters
plus a crossterm ticker `JoinHandle`. `start()` is named deliberately
(not `new()`) because it spawns a thread — `Default` semantics would be
misleading. `Drop` joins the ticker and clears the line.
- `copy_leaf` returns `Result<Option<u64>>` (bytes on copy, `None` on
skip). Bytes come from the `symlink_metadata` we already stat, so no
extra syscall — actually one fewer on Unix since we now reuse it for
permissions too.
- `copy_dir_recursive` returns `Result<(usize, u64)>` and takes
`&CopyProgress`; rayon workers bump `progress.file_copied(bytes)`. The
summary in `step_copy_ignored` reuses the totals.
- New skill section in `.claude/skills/writing-user-outputs/SKILL.md`
pointing future authors at `CopyProgress` as the spinner pattern.

### Testing

Unit tests cover `format_count`, `format_bytes` (IEC), `format_line`,
`render_line`/`clear_line` (against an in-memory `Vec<u8>`), the
disabled no-op path, and the enabled ticker lifecycle (via a
test-module-local helper that bypasses the TTY check). Manual smoke via
`script(1)`: big copies render the in-place spinner cleanly; small
copies (<300ms) render nothing beyond the summary; pipes, `-v`, and
`--dry-run` are unchanged.

`codecov/patch` covers ~94% of the diff. The remaining ~10 uncovered
lines are the enabled branch of `start()` (Arc allocation + thread
spawn), which can't be reached without a real TTY in tests. Equivalent
logic is exercised by the test-module helper.

Closes #2401. Thanks to @tehdb for the suggestion.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 16:36:42 -07:00
Maximilian Roos 073a128ad3 perf(alias): benchmark parent-side alias dispatch overhead (#2340)
## Summary

Adds `benches/alias.rs` to measure the parent-side dispatch overhead of
`wt <alias>` — the wall-clock cost paid *before* the alias body runs
(config load, repo open, template context build, fork+exec of the child
shell).

Issue #2322 reports `wt <alias>` as dramatically slower than the
equivalent subcommand. Commit 4f9bd575a already shipped the first fix
(O(1) upstream lookup in `build_hook_context`). This bench is the
regression guard so future changes in the dispatch path are caught with
measurement.

One group (`dispatch`), five variants:

- `wt_version` — `wt --version` startup floor, no repo discovery
- `warm/1`, `warm/100`, `cold/1`, `cold/100` — noop alias (`echo hello`)
at 1 and 100 worktrees, warm and cold caches

Each worktree gets its own branch, so the 100-worktree variants also
exercise ~101 branches. The `cold/100` row is specifically positioned to
catch regressions to the pre-4f9bd575a bulk `for-each-ref` — that path
is O(branches) and slow when packed-refs aren't in OS cache.

## Local results

20 samples, 2s warmup, 8s measurement:

```
dispatch/wt_version     time:   [5.17  5.29  5.43] ms     # startup floor
dispatch/warm/1         time:   [81.2  82.5  84.0] ms
dispatch/cold/1         time:   [93.3  99.2  104.9] ms
dispatch/warm/100       time:   [80.4  81.7  83.0] ms
dispatch/cold/100       time:   [80.8  82.1  83.4] ms
```

Readouts:
- Parent-side dispatch adds ~77 ms on top of the ~5 ms binary startup.
- Flat across warm/cold × 1/100 worktrees (~82 ms) — dispatch is O(1) in
branch/worktree count and roughly insensitive to cache state, matching
expectations post-4f9bd575a. (`cold/1` has wider variance but overlaps
the warm intervals.)

## Test plan

- [x] `cargo bench --bench alias -- --sample-size 10` runs end-to-end
- [x] `cargo run -- hook pre-merge --yes` passes (tests + lints)
- [x] Pre-commit hooks pass

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 22:07:27 +00:00
Maximilian Roos 83d6d076bb bench: invalidate wt caches per iteration in remove/TTFO benches (#2341)
## Summary

`wt remove` populates `.git/wt/cache/` via `compute_integration_lazy`
whenever `BranchDeletionMode` isn't force-delete (the CLI's `--force` is
`force_worktree`, not `--force-delete`). The `remove_e2e/first_output`
and `first_output/remove` benches used `b.iter` with no invalidation, so
iter 1 was cold and iter 2+ hit the cache — reported timings reflected
warm-cache cost, not the first-invocation TTFO users see.

## Changes

- `benches/remove.rs::first_output` and
`benches/time_to_first_output.rs::remove` switch to `iter_batched` +
`invalidate_caches_auto`.
- `wt_perf::invalidate_caches_auto` now also unsets
`worktrunk.default-branch` (git config). That cache contributes ~17ms
per iteration on a typical-8 repo (166ms with default-branch cached vs
183ms fully cold) — measurable but small enough that always clearing it
is simpler than introducing a second mode.
- `benches/CLAUDE.md` documents the rule, the full list of what
`invalidate_caches_auto` clears (including what it deliberately
preserves — `worktrunk.history`, `worktrunk.hints.*`,
`worktrunk.state.*`, `.git/wt/logs/`, `.git/wt/trash/`), and which
commands populate `.git/wt/cache/`.

Audit found no other benches affected: `completion.rs` and `cow_copy.rs`
don't touch the cache; `list.rs` already wires warm/cold via
`BenchConfig`; `wt switch` and `wt list` under
`WORKTRUNK_FIRST_OUTPUT=1` exit before the writers run;
`remove.rs::no_hooks`/`with_hooks` short-circuit at `same_commit`
because `recreate_worktree` rebuilds the branch at main's HEAD.

> _This was written by Claude Code on behalf of Maximilian Roos_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 12:49:32 -07:00
Maximilian Roos d2d38dd458 docs(perf): require --progressive in wt-perf trace pipeline and trim duplication (#2258)
Two TTY-gated trace events (`Skeleton rendered`, `First result
received`) don't fire when `wt list` detects a non-TTY stdout, which
every piped `wt-perf` invocation does. Every documented capture pipeline
now passes `--progressive` so all seven instant events flow through
`wt-perf trace` / `cache-check`. Fixes 10 sites across module docs, clap
`after_long_help`, setup eprintln, and `benches/CLAUDE.md`.

While in the area, I also consolidated some of the scattered examples:

- Dropped the `# CLI Usage` block from
`tests/helpers/wt-perf/src/lib.rs` — library docs shouldn't carry CLI
examples, and they fully duplicate `wt-perf --help`.
- Trimmed the `main.rs` module docstring from four example invocations
to a one-liner pointing at `wt-perf --help`.
- Dropped the "with benchmark repo" variant from `cache-check`'s
`after_long_help`, since it's structurally identical to the first
example with `-C <path>` prepended.
- Replaced the stdin-hint and no-entries error in `wt-perf` (which
repeated the full pipeline) with a pointer to `<subcommand> --help`.
- Pruned `src/trace/chrome.rs`'s standalone usage example to defer to
`crate::trace`.

Verified end-to-end: `RUST_LOG=debug wt list --progressive 2>&1 |
wt-perf trace` emits all seven expected instant events in the Chrome
Trace JSON.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 22:07:56 -07:00
Maximilian Roos c3484b3b63 docs(perf): drop redundant grep wt-trace from wt-perf pipelines (#2257)
The trace parser already filters for the `[wt-trace]` marker internally
(`parse_lines` → `parse_line` uses `line.find("[wt-trace] ")?`), so the
upstream `grep` was redundant. Dropping it makes the documented
pipelines shorter and removes an inconsistency between callers that used
`grep wt-trace` vs `grep '\[wt-trace\]'`.

Changes span 12 sites across 6 files: module docs, `wt-perf`'s own CLI
help (trace/cache-check subcommands, setup eprintln, stdin hint,
empty-input error), `benches/CLAUDE.md`, and the `running-tend` skill.
Also fixed a stale reference in `src/trace/chrome.rs` to the old
`analyze-trace` binary name (now `cargo run -p wt-perf -- trace`).

Verified end-to-end: `RUST_LOG=debug wt list 2>&1 | wt-perf trace`
produces valid Chrome Trace JSON, and `| wt-perf cache-check` produces
the expected structured report.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 21:27:52 -07:00
Maximilian Roos d50bdfcde0 Cache remaining expensive tasks, unify wt list and picker (#2098)
Adds persistent SHA-keyed caching for `is_ancestor`,
`has_added_changes`, and `branch_diff_stats` — the last three operations
still running unconditionally in both `wt list` and the `wt switch`
picker. With all five merge-base-dependent tasks now cached (joining
`merge-tree-conflicts` and `merge-add-probe` from PR #2085), the
`stale_branches` skip mechanism becomes redundant: the picker's "skip
tasks on branches > 50 behind main" optimization was papering over slow
first-run cost that the cache now handles eternally.

## What changed

**New caches** (`src/git/repository/probe_cache.rs`): three new
asymmetric SHA-keyed kinds — `is-ancestor`, `has-added-changes`,
`diff-stats` — all using the same LRU-swept persistent file layout as
the existing kinds. `branch_diff_stats` skips its cache when sparse
checkout is active (path filters make the result environment-dependent).

**Dead plumbing removed**: `EXPENSIVE_TASKS` constant,
`CollectOptions::stale_branches` field, `skip_expensive_for_stale`
parameter on `collect()`, the env-var gate in `list/mod.rs`, and two
obsolete tests + snapshots. Both `wt list` and the picker now run the
same task set.

**`batch_ahead_behind` unconditional**: previously only the picker
called it. Making it unconditional replaces N `rev-list --count` calls
with one `for-each-ref`, a pure win for `wt list` too.

**Branch-ref semantics unified across all tasks**: all task
implementations now prefer the branch name over
`ctx.branch_ref.commit_sha` when one is present. Previously,
`IsAncestorTask`, `BranchDiffTask`, `MergeTreeConflictsTask`, and
`CommittedTreesMatchTask` used the worktree's current HEAD sha, which
during a rebase-in-progress is transiently at the replayed commit rather
than the branch tip. This produced contradictory rows:
`is_ancestor=true` + `1 ahead / 1 behind`. Now all tasks consistently
report the branch's state, matching `git status` semantics. Addressed
worktrunk-bot review feedback.

**Caching docs updated**: the `## Caching` section in `collect/mod.rs`
(from PR #2097) now lists the three tasks as cached rather than
"cacheable but uncached". Also addressed worktrunk-bot PR overlap
observation.

**Benchmarks**: `invalidate_caches_auto()` now clears `.git/wt/cache/`
so cold-cache benchmarks exercise cold state. The `warm_optimized`
variant in `real_repo_many_branches` collapsed since it no longer
measures anything different from `warm`.

## Testing

New probe_cache unit tests cover roundtrip + tamper-based cache
consultation for each new kind, plus a `clear_all_covers_all_kinds`
regression test. Snapshot updates for
`test_list_maximum_status_with_git_operation` and
`test_list_json_with_git_operation` reflect the branch-ref fix —
mid-rebase worktrees now show `✗` (real conflicts) and actual diff stats
instead of the transient HEAD's empty state.

> _This was written by Claude Code on behalf of @max-sixty_

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:58:03 -07:00
Maximilian Roos 679fe539fe Deprecate --no-verify in favor of --no-hooks (#1932)
`--no-hooks` describes what the flag does — skip hooks. `--no-verify`
was inherited from git's naming but doesn't match worktrunk's semantics
(there's no "verification" step being skipped).

`--no-verify` remains as a hidden alias that emits a deprecation
warning, retained for at least one release cycle per the project's
deprecation policy.

Changes across switch, remove, merge, step commit, and step squash:
- `--no-hooks` is the canonical visible flag
- `--no-verify` hidden, emits `▲ --no-verify is deprecated; use
--no-hooks instead`
- Error hints (`↳ To skip pre-merge hooks, re-run with --no-hooks`),
info messages, help text, docs, and config examples all updated
- `resolve_verify()` helper in main.rs deduplicates the deprecation
logic
- Backward-compatibility test verifies `--no-verify` still works

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-06 15:01:18 -07:00
Maximilian Roos 6326068b80 perf: switch diff stats from --numstat to --shortstat (#1917)
The three callsites that compute diff totals (`branch_diff_stats`,
`working_tree_diff_stats`, `diff_stats_summary`) were using `--numstat`,
which outputs one line per changed file. Since we only sum the totals,
`--shortstat` gives the same data in a single line — reducing command
output from O(files) to O(1) per worktree.

Most noticeable with `-vv`, where all command output is logged at debug
level. A branch touching 500 files now produces 1 line of debug output
instead of 500.

The `--numstat` path (via `parse_numstat_line`) is retained for
`log_formatter.rs`, which needs per-file stats for individual commits.
`DiffStats::from_numstat` and `LineDiff::from_numstat` are removed — no
production callers remained.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-05 18:57:41 -07:00
worktrunk-bot f6e89009e5 fix: detect squash-merged branches when merge-tree conflicts (#1820)
## Problem

`wt step prune` (and `wt remove`) fail to detect squash-merged branches
when the default branch has since modified the same files that the
branch touched. This is because `git merge-tree --write-tree` reports
conflicts when both sides changed the same files, and the code
conservatively treated conflicts as "not integrated."

The same issue affects `wt list` — the `WouldMergeAdd` task calls the
same function, so the integration symbol `⊂` is not shown for these
branches.

## Solution

When `git merge-tree --write-tree` conflicts, fall back to **patch-id
matching**: compute the branch's squashed patch-id (`git diff-tree -p
merge-base..branch | git patch-id --verbatim`) and check if any commit
on the target has a matching patch-id. This detects squash merges
because the squash-merge commit on the target has the exact same content
changes as the branch.

Uses `--verbatim` (not `--stable`) to avoid false positives from
whitespace normalization — `--stable` strips whitespace, so
tabs-vs-spaces would produce matching patch-ids even though file content
differs.

The fallback is only triggered when merge-tree conflicts — the happy
path (no conflicts) is unchanged.

The merge-tree → patch-id sequence is extracted into
`Repository::merge_integration_probe()`, a shared method used by both
`wt list` (parallel tasks) and `wt remove`/`wt merge` (sequential path).
This fixes a drift where the two call sites handled patch-id errors
differently.

## Known limitation

The patch-id fallback checks if the branch's diff was **ever** applied
to the target, not whether the effect **persists**. If a squash merge is
later reverted on the target and then the same files are modified
(causing merge-tree conflicts), the historical squash-merge commit's
patch-id still matches. This is documented in #1818. A follow-up could
add a revert-detection check after finding a match.

## Testing

- `test_remove_squash_merged_then_same_files_modified` — reproduces the
exact scenario from #1818 (branch modifies file, squash-merged, then
target modifies same file)
- `test_prune_squash_merged_same_files_modified` — verifies `wt step
prune --dry-run` detects the branch
- All existing squash-merge tests continue to pass (5 remove + 22 prune
tests)

Closes #1818

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-04-01 19:05:54 -07:00
Maximilian Roos 6279905525 docs: restructure trace queries around performance questions (#1884)
Replaces the ad-hoc trace query collection (including the narrow
status-vs-trees overlap query) with queries organized around three
performance questions:

1. **Where does time go?** — slowest commands + task type breakdown
(kept from previous PR, reorganized)
2. **How parallel are we?** — new query comparing total subprocess time
to subprocess span
3. **What's on the critical path?** — phase durations from milestones +
per-worktree breakdown via `EXTRACT_ARG`. Honestly noted as incomplete —
the trace format doesn't capture task dependencies, so Perfetto
visualization is better for real critical path analysis.

All queries validated against a real trace from a `typical-4` benchmark
repo.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-01 18:45:55 -07:00
Maximilian Roos afa0a478c9 refactor: isolate benchmark commands and consolidate shared utilities into wt-perf (#1878)
Add `isolate_cmd` and `run_git_ok` to the shared `wt-perf` crate and
call `isolate_cmd` from all benchmark command sites. Previously only
`benches/remove.rs` isolated commands from the host environment; the
other benchmarks inherited host `GIT_*`, `WORKTRUNK_*`, `SHELL`, and
`NO_COLOR` vars, making results non-reproducible across environments.

`isolate_cmd` takes `Option<&Path>` for the user config — `None` uses a
nonexistent path (defaults only), `Some(path)` uses a real config file
(e.g., `remove.rs` with hooks). `run_git_ok` is the non-panicking
variant of `run_git` for best-effort cleanup operations.

> _This was written by Claude Code on behalf of @max-sixty_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-01 13:45:54 -07:00
Maximilian Roos ea1eb0fda3 docs: complete trace event SQL categories in benchmarks (#1873)
The task type breakdown query in `benches/CLAUDE.md` only had 5
categories, leaving most commands as "other". Surveyed all git commands
from `wt list`, `wt list --branches`, and `wt list --full --branches`
traces against a `typical-4` benchmark repo to build a complete taxonomy
of 22 categories.

Key fixes: tightened `%rev-parse%tree%` to `%rev-parse%{tree}%` (was
misclassifying `--is-inside-work-tree` as `trees_match`), added
`rev_list` for commit counting, `diff_3dot`/`diff_wt` for full-mode
diffs, `sparse_checkout`, `llm_summary` for Claude commit summaries, and
all remaining standard categories. Verified zero commands fall to
"other" across all three trace modes.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-01 11:28:11 -07:00
Maximilian Roos b1ab14c488 chore: remove run_git_ok helper from remove benchmark (#1871)
Inline the two cleanup calls using `Cmd::new("git")...run()` directly
instead of a dedicated `run_git_ok` wrapper — the same pattern used
throughout the codebase for fallible git commands.

Follow-up to #1858.

> _This was written by Claude Code on behalf of maximilian_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-01 10:49:51 -07:00