2274 Commits

Author SHA1 Message Date
Maximilian Roos 9ecd7945e0 fix(merge): measure squash/rebase against the target's upstream, not a stale local ref (#3549)
Fixes #3519.

## The bug

`wt merge` resolves its target to the **local** default-branch ref and
squashes/rebases the span `merge-base(local-target, HEAD)..HEAD`. When
the primary checkout's local `main` is behind `origin/main` **and** the
branch descends from the newer `origin/main` tip (e.g. created with
`--base origin/main`), that merge-base falls back at the stale local
tip, so the span sweeps in every commit already on `origin/main`. The
squash folds them into a single commit and the local fast-forward lands
it — corrupting the primary checkout's `main` and, if that ref is later
pushed, duplicating already-upstream content under new SHAs. Reproduced
deterministically before fixing: a 1-commit feature off `origin/main`,
with local `main` two commits behind, squashed "3 commits" into a new
SHA on local `main` — matching the report.

The same mis-measured span reaches every rewrite entry point: a
standalone `wt step squash` folds the upstream commits into the *branch*
(the reporter's 1-commit PR becoming 13), and `wt step rebase` replays
them onto the stale ref when it fires.

## The fix: measure against the upstream; let the merge carry

One local-only predicate (no fetch — worktrunk is local-first),
`Repository::span_upstream`: the target's upstream, returned exactly
when the branch's history extends past the local target ref into it —
i.e. `merge-base(HEAD, upstream)` is not an ancestor of the local
target. The rewrite pipeline responds by *measuring* there instead of
guessing from the stale ref:

- **`wt step squash`** (and the squash inside `wt merge`) computes its
span, commit count, message, and `reset --soft` base against the
upstream — only the branch's own commits fold, and the target ref is
never touched. The `--dry-run`/`--show-prompt` previews use the same
base.
- **`wt step rebase`** (and the rebase inside `wt merge`) rebases *onto*
the upstream, so a non-linear branch replays only its own commits
instead of duplicating upstream ones onto the stale ref. In the plain
stale topology it no-ops, as before.
- **`wt merge`** announces the situation up front, then completes: the
fast-forward carries local `main` through the already-fetched upstream
commits by their real SHAs to the result — the same graph a fresh local
`main` would have produced:

  ```
○ Local main is 2 commits behind origin/main; the merge fast-forwards
through them
  ◎ Squashing 2 commits into a single commit (2 files, +2)...
  ✓ Squashed @ 3f2a91c
  ✓ Merged to main (3 commits, 3 files, +3)
  ```

The one refusal left is forced by topology rather than chosen by policy:
a target that has **diverged** from its upstream (its own commits *and*
behind, with the branch based past it) can never fast-forward in any
mode — reconciling the target's own commits is the user's call, so the
merge stops before any rewrite or approval prompt:

```
✗ Local main has diverged from origin/main — can't fast-forward to a branch based on origin/main
↳ Reconcile main with origin/main (rebase or merge its local commits), or specify a different target
```

This is the reporter's suggested direction ("use `origin/<default>` as
the merge/rebase target"), minus the network: the already-fetched
remote-tracking ref is the measurement base, and `wt merge` still never
touches the wire. It also extends the rule the rest of worktrunk already
applies — `IntegrationTargets` / the preview panes' comparison base
treat the upstream as the truth when the local default lags it; the
merge pipeline was the one mutating surface that didn't.

Quiet on everything that must keep working: a legitimately-diverged
local target with the branch forked from the shared base (the supported
offline workflow — existing regression test unchanged), targets with no
upstream, orphan branches, and explicit non-branch targets like `wt step
squash origin/main`.

## Tests

- `test_merge_carries_stale_local_main_through_upstream` — the #3519
topology end-to-end: merge succeeds, the carry is announced, the squash
folds only the branch's own commits and sits directly on the
`origin/main` tip, and `origin/main` is an ancestor of the merged `main`
(real SHAs, no duplication).
- `test_step_squash_measures_against_upstream_when_local_main_stale` —
the standalone squash folds only the branch's own commits and never
moves the target ref (this scenario previously corrupted `main` through
a squash-then-merge sequence).
- `test_step_rebase_targets_upstream_when_local_main_stale` — a branch
that merged `origin/main` in rebases onto the upstream: linearized, real
upstream SHAs retained, target ref untouched.
- `test_merge_refuses_diverged_target_when_branch_based_on_upstream` —
the forced refusal: nothing mutated, branch survives.
- `test_merge_removes_branch_when_local_main_diverged_from_upstream`
(legitimate divergence, branch from shared base) still passes — no
regression.

Docs: the merge help page (primary source in `src/cli/mod.rs`) describes
the upstream-aware measurement, the carry, and the diverged refusal;
generated mirrors and help snapshots regenerated.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:34:16 -07:00
Maximilian Roos 14d4d92532 feat(statusline): make the CI and URL segments clickable in Claude Code (#3550)
The statusline suppressed OSC 8 hyperlinks in Claude Code mode, so its
CI segment was colored but not clickable and its dev-server URL printed
in full. Claude Code renders OSC 8, so both segments now link the way
they already do in `wt list`.

```
 ~/w/worktrunk.statusline-osc8-hyperlinks  ↕|🤖  ↑2 ↓1  ^+93 -24  #3550  :17913  Opus 4.8
                                                                   └link┘  └link┘
```

## Why it was off

The gate dated from 2026-01-02, inside Claude Code's OSC 8 regression
window — links worked through 2.0.76, broke around 2.1.3, and got a
partial IDE-terminal-only fix in 2.1.42
([anthropics/claude-code#26356](https://github.com/anthropics/claude-code/issues/26356)).
The premise was correct when written and has since expired; that issue
was auto-closed for inactivity rather than on a fix, so the tracker
understates current support.

## Verification

PTY-captured the raw byte stream from Claude Code 2.1.218, with
`TERM_PROGRAM`/`VSCODE_*` stripped so it exercises the
standalone-terminal path that was reported broken. Claude Code doesn't
strip or blindly pass through — it parses OSC 8 into its frame model,
assigns a hyperlink id, and re-emits canonically (normalizing an ST
terminator to BEL):

```
PRE ESC]8;id=1l7bqdh;https://example.com/ST-PROBE BEL STLINKTEXT ESC]8;; BEL  MID …
```

It holds in both the normal and alt-screen (`CLAUDE_CODE_NO_FLICKER=1`)
render paths, with `FORCE_HYPERLINK` unset. Driving the real `wt` binary
through Claude Code end to end yields both links live:

```
LINK: https://github.com/max-sixty/worktrunk/pull/3550
LINK: http://127.0.0.1:17913
```

Degradation is graceful: a terminal or multiplexer that drops OSC 8
shows the same text, just not clickable (tmux only gained OSC 8 in 3.4;
zellij and Alacritty support it).

## Shape of the change

With links unconditional for the statusline, the plumbed `include_links`
flag had one value, so it collapses into the segment builder.
`format_url_cell` likewise takes the link decision from its caller
rather than probing the terminal, matching `PrStatus::format_cell` — the
statusline's stdout is a pipe, so `supports_hyperlinks` reports false
there even though the consumer renders OSC 8. That left
`hyperlink_stdout` with no callers, so it goes.

`format_cell` keeps its `include_link` parameter: `wt list` passes the
terminal probe, and the picker passes `false` because a `--prs` row
never reaches the strip path.

`format_url_cell` moved next to `estimate_url_width`, which budgets the
column against it — the two have to agree on when a cell collapses to
`:port` and were in separate files.

The URL segment also gets shorter: the URL rides inside the escape
sequence, so `http://127.0.0.1:17913` becomes `:17913`, returning 16
columns on a line that budgets by width.

## Safety of the truncation interaction

`truncate_visible` ends its cut with `\e[0m`, which resets colour but
leaves an OSC 8 link *open* — a severed link would make the rest of the
terminal line clickable, and `ansi_cut` really will sever one if
reached.

It can't be reached, because the two cuts never meet: `fit_to_width`
drops whole segments worst-priority-first and stops at one, so character
truncation only ever lands on a best-priority survivor — Directory (0),
Branch or Model (1) — none of which carry escapes beyond SGR. Every
link-bearing segment is strictly worse (CI 5, URL 9), so each is dropped
entire first.

Reviewing the branch turned up that the numbered comments in
`format_statusline_segments` had drifted from `COLUMN_SPECS` — CI was
labelled 9 (it is 5) and the URL 8 (it is 9), with branch-diff and
upstream also off — and the first version of the test had taken those
stale numbers as its specification. The comments are corrected and the
test now rests on the invariant above, which doesn't depend on where CI
sits. It sweeps widths 1–90 over both links, asserts it spans every drop
stage, and pins that the URL goes before CI.

A second test pins that the hidden URL costs no visible width
(`ansi_strip` drops OSC 8 for both terminators), so priority budgeting
isn't inflated.

## Docs

The Claude Code statusline page now says the segments are clickable —
it's the feature's own page and said nothing about it. The `wt list
--help` JSON field description gains the links but stays short: an
earlier, longer wording shrank the help table's Field column and wrapped
two dozen unrelated rows.

## One judgement call worth flagging

`format_statusline_segments` also feeds plain `wt list statusline`
(shell prompts) and the JSON `statusline` field. The CI link was already
unconditional on both before this change; what's new is that the URL
cell renders as a linked `:3000` rather than the full URL, so a consumer
that strips OSC 8 sees only `:3000`. The structured `url` /
`dev_server.url` field still carries the full URL, and `:3000` still
answers "which port", so this reads as the right trade — but it is the
one place the collapse to a constant reaches a renderer that isn't
Claude Code.

🤖 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-07-23 14:20:01 -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 f14fa69c0e feat(list): run merge analysis in a read-only object database (#3535)
When the Git object database is read-only, `wt list` and `wt list statusline` redirect their object-writing merge/conflict probes (write-tree, commit-tree, merge-tree --write-tree) into a temporary object database with the real one as a read-only alternate, so the full analysis runs in a managed read-only sandbox. Detection is a one-time writability probe; the writable path is unchanged. Scoped to the two observational list entry points (collect and populate_item); mutating commands keep the persistent database and fail loudly on a read-only store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:14:20 -07:00
Worktrunk Bot dbefb98ed4 fix(plugin): add pipefail to WorktreeCreate hook so wt failures surface (#3546)
## Problem

The Claude plugin's `WorktreeCreate` hook
(`plugins/worktrunk/hooks/hooks.json`) pipes the `wt` result straight
into `jq`:

```
bash -c 'name=$(jq -er .name) || exit 1; cd "${CLAUDE_PROJECT_DIR:-.}" || exit 1; bash "$CLAUDE_PLUGIN_ROOT/hooks/wt.sh" switch --create "$name" --no-cd --format=json | jq -er .path'
```

Without `set -o pipefail`, the pipeline's exit status is `jq`'s, not
`wt`'s. On jq 1.6, `jq -er .path` exits **0** on the empty stdout of a
failed `wt`:

```console
$ printf '' | jq -er .path; echo $?   # jq 1.6
0
```

So when `wt switch --create` dies (e.g. an existing-branch collision
after the branch/worktree were already created by an earlier partial
run), the hook "succeeds" while printing nothing. Claude Code then
reports the misleading `WorktreeCreate hook failed: hook succeeded but
returned no worktree path` instead of `wt`'s real stderr — as reported
in #3545.

The project's own `skills/wt-switch-create/rationale.md` already
documents the `bash -c 'set -o pipefail; …'` wrapper as the intended
design ("The hooks.json pipefail wrapper"), but the command in
`hooks.json` never actually carried it. This PR brings the code in line
with its own documentation.

## Solution

Prefix the existing `bash -c` command with `set -o pipefail;`. The
wrapper is already `bash -c`, so this is safe — dash rejects `set -o
pipefail` fatally, which is precisely why the `bash -c` wrapper (rather
than the login shell) exists per the rationale. With pipefail, the
pipeline surfaces `wt`'s nonzero exit regardless of jq version.

## Testing

Added a reproduction assertion to `test_plugin_layout_is_consolidated`
(`tests/integration_tests/config_show.rs`) that fails when the
`WorktreeCreate` hook command lacks `set -o pipefail` — it fails on the
pre-fix `hooks.json` and passes after. Also verified the pipeline shape
directly:

```console
$ bash -c 'set -o pipefail; (echo "✗ Branch foo already exists" >&2; exit 1) | jq -er .path'; echo $?
✗ Branch foo already exists
4                       # nonzero -> hook correctly fails, wt's stderr shown
$ bash -c 'set -o pipefail; echo "{\"path\":\"/tmp/wt/foo\"}" | jq -er .path'; echo $?
/tmp/wt/foo
0                       # success path unchanged
```

The `bash -c '…'` body stays single-quoted, so
`test_claude_hook_commands_parse_in_all_shells` (login-shell parse
check) is unaffected — the outer shells never look inside the quotes.

---
Closes #3545 — automated triage

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-22 11:07:12 -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
Maximilian Roos 0aca156ccd fix: use @ before path in -vv startup pointer message (#3543)
The \`-vv\` startup pointer (\`announce_trace_destination\` in
\`src/logging.rs\`) printed "Verbose logging to \<dir\>/", using "to"
before the path. The codebase convention (see the
\`writing-user-outputs\` skill) is \`@\` before paths in user-facing
output — #3521 already applied this to the \`-vv\` end-of-run block but
left this start-of-run pointer as the one remaining inconsistency.

Changes "Verbose logging to \<dir\>/" to "Verbose logging @ \<dir\>/"
and updates the matching test assertions/doc comment in
\`tests/integration_tests/diagnostic.rs\`. The historical wording quoted
in \`CHANGELOG.md\` is left as-is since it documents a past release.

> _This was written by Claude Code on behalf of max_
2026-07-22 02:46:21 -07:00
Worktrunk Bot a660d859ee refactor: unify subdir preservation for wt step relocate (#3346)
Route `wt step relocate`'s shell `cd` through the shared `resolve_subdir_in_target` helper, so every path-switching command (`switch`, `remove`/`merge`, `relocate`) preserves the user's subdirectory position identically — resolving the follow-up from #3344.

Gate `test_relocate_preserves_subdir` to Unix: subdir preservation only fires when the cwd is inside the moving worktree, which is exactly when `git worktree move` (a directory rename) fails on the legacy Windows console — a real limitation, not a test artifact, so the test documents the boundary rather than masking it.
2026-07-22 02:34:59 -07:00
Maximilian Roos 6a85da8f46 refactor(eval): render -v variables through the shared formatter (#3536)
`wt step eval -v` was the last of the four `-v` template-variable
listings to hand-roll its own formatting. The hook (foreground +
background) and alias sites all render through `format_hook_variables` /
`format_alias_variables`; eval instead sorted keys alphabetically,
padded, and joined them by hand. #3495 labelled all four block *headers*
with their owner, which made eval's differently-formatted *body* stand
out.

This adds a sibling `format_base_variables` for the bare `base_vars`
scope (no hook operation/infrastructure vars, no alias `args`) and
routes eval through it, so all four listings are one family.

Consequences for the eval listing, now matching the other three:

- curated help-table order instead of alphabetical
- deprecated aliases (`main_worktree`, `repo_root`, `worktree`,
`main_worktree_path`) dropped, as the other sites already omit them
- absent optional vars show `(unset)` (e.g. `owner` with no PR context)
- variable names render plain, not bold

Behavior-only change to the `-v` diagnostic lane; the `eval_verbose` /
`eval_format_json_verbose` snapshots capture the new output.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 02:27:19 -07:00
Worktrunk Bot 56249f9122 fix: clear error when new branch name collides with existing branch namespace (#3528) 2026-07-20 07:08:09 -07:00
Worktrunk Bot c6eb148e62 docs(hook): correct pre-switch {{ branch }} resolution note (#3516) 2026-07-19 18:13:17 -07:00
Maximilian Roos 17add1f6dd fix: drop companion log paths from the -vv end-of-run message (#3521)
The `-vv` end-of-run block printed `trace.jsonl` and `subprocess.log` as
bare gutter lines under a headline that named only `diagnostic.md` — an
unannounced list whose relationship to the headline lived in a code
comment, and the third and fourth prints of the same directory in one
run. The start-of-run pointer already names the log directory, and the
report body already routes readers to the raw companions
(`subprocess_log_path` in the template; the profile heading names
`trace.jsonl` as its source), so the end block now announces only the
one human-facing file:

```
○ Diagnostics and performance profile saved @ ~/…/.git/wt/logs/diagnostic.md
↳ To report a bug, open an issue (https://github.com/max-sixty/worktrunk/issues/new) and attach a secret gist: gh gist create --web ~/…/diagnostic.md
```

The headline drops "Logs," (the raw logs were precisely what wasn't at
that path), and the hint now ends with the copyable gist command,
linking the plain `/issues/new` URL instead of the ~150-char
prefilled-body URL.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:39:49 -07:00
Worktrunk Bot cda5080195 test(list): exercise real wide-char branch names in width test (#3511) 2026-07-18 07:35:37 +00:00
Rene Leonhardt 79be81a4bb fix(merge): Accept merge-shaped fast-forward histories (#3509) 2026-07-18 00:32:56 -07:00
Maximilian Roos 85c9241cb1 Name the template owner in the -v variables block (#3495)
## The question

When `wt` shows a `template variables:` block under `-v`, does it say
which template the variables belong to? Mostly no — and in one case it
genuinely can't be worked out from the header.

## What was there

Four sites print a variables listing, and none named its owner in the
header:

| Site | Header | How you'd tell what it's for |
|---|---|---|
| foreground hook (`command_executor.rs`) | `template variables:` | the
`hook_type` row at the bottom of the table, or the `Running …` line
below it |
| background hook (`hooks.rs`) | `template variables:` | same — but see
below |
| alias (`alias.rs`) | `template variables:` | only the `Running alias
greet` line below it |
| `wt step eval` (`eval.rs`) | `Available template variables` |
unambiguous (one template, named on the command line) |

The adjacent blocks in the same `-v` lane already label themselves — `○
eval source`, `○ user:noop result` — so the variables table was the odd
one out in its own family.

Background hooks turned that from inconvenient into ambiguous. `wt -v
remove <branch>` prints one table per hook type back to back, then a
*single* combined `Running post-remove: cleanup (user); post-switch:
notify (user)` line — two identical headers over two ~20-row tables that
differ only in their rows.

## The change

Prefix each header with the hook type, alias name, or `eval`.

```
- ○ template variables:                 ○ post-remove template variables:
- ○ template variables:                 ○ post-switch template variables:
```

`wt step eval -v` now reads as one labeled family:

```
○ eval template variables:
  branch = feature/auth-oauth2
○ eval source
  {{ branch }}
○ eval result
  feature/auth-oauth2
```

`eval`'s header was reworded from `Available template variables` for the
same shape; it was never ambiguous, but it's the fourth member of the
family and now matches the `source` / `result` headers directly beneath
it.

Display-only — no logic changed. Snapshots and the generated `step.md`
pages regenerated; `cargo run -- hook pre-merge --yes` green (4461
tests).

> _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 (1M context) <noreply@anthropic.com>
2026-07-16 21:36:58 -07:00
Maximilian Roos 568b6de85f docs: consolidate duplicated explanations and trim slop (#3494)
Sweep of the docs for repetition and filler, from an audit of the
hand-authored pages, the command pages' source in `src/cli/mod.rs`, and
the plugin skill. Net −574 lines; every cut either had a surviving
canonical home or restated an adjacent sentence.

**One home per mechanism** (other mentions now link to it):

- `template-append` — the LLM commits guide owns the rendering
mechanism; the user- and project-config sections keep the key, an
example, and what is unique to them (the project approval gate and the
only-`template-append`-from-project scoping). Was explained in full in
three places.
- `-vv` diagnostic files — `wt config state logs` owns the four file
descriptions; the FAQ summarizes in one sentence and links.
- fsmonitor/trash cleanup — the FAQ's two overlapping `wt remove`
bullets merge into one that distinguishes own-daemon teardown from the
orphan sweep; troubleshooting.md's restatement compresses to a pointer,
keeping its unique wedged-daemon-on-live-worktree guidance.
- copy-ignored built-in excludes — the `wt step copy-ignored` page owns
the directory list (previously enumerated 4×); the config sections state
the rule and link.
- hook-types table — `wt hook` owns it; the extending guide replaces its
verbatim copy with a sentence.
- LLM tool commands — unchanged, deliberately: the apparent hand-synced
duplication between llm-commits.md and the config example is already
machine-pinned by `test_llm_docs_commands_match_config_example`, so it
cannot drift.

**Cut-over debt**: the deprecated `wt config state ci-status` section
shrinks to a deprecation pointer — its status table, fetch order, and
caching notes all duplicated the `wt list` CI-status section.

**Structure**: `wt step copy-ignored`'s "Features" list dissolves into
the sections that owned its facts (excludes → "What gets copied",
reflink → "Performance"); the four trailing "Note: This command is
experimental…" lines go (the `[experimental]` badge already appears
twice per section); shell-integration.md described the directive-file
mechanism twice and now describes it once.

**SKILL.md** drops from 339 to 181 lines: the permission-model section
duplicated the config-types bullets, the hook-type mapping appeared
twice, and the "Determining Which Config to Use" / "Validation Before
Adding Commands" scaffolding enumerated judgments an agent makes on its
own. The approvals-escalation and agent-handoff sections are untouched.

**Deliberately not addressed**: `wt list`'s schema 1 JSON tables (still
the default schema; the wholesale deletion lands when the default flips)
and corpus-wide em-dash density (a house-style decision, not a per-page
fix).

Generated mirrors (docs command pages, skill references, plugins mirror,
`dev/*.example.toml`, help snapshots) regenerated via
`test_docs_are_in_sync` and `cargo insta`.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 13:44:44 -07:00
Worktrunk Bot 7735d115ce fix(tests): restore hidden-subcommand completion coverage (#3492) 2026-07-16 01:24:55 -07:00
Maximilian Roos 0a1cd19cc9 chore: drop the NEXTEST_NO_INPUT_HANDLER workaround (#3482)
nextest#2878 (the run suspending with SIGTTOU when a test spawns an
interactive shell, as the `shell-integration-tests` feature does) was
fixed in nextest 0.9.118, so the `NEXTEST_NO_INPUT_HANDLER=1` workaround
threaded through the insta hook, CI, and the test docs is obsolete.
Verified on nextest 0.9.132: the shell-wrapper tests run to completion
under a real PTY without the variable.

`.config/nextest.toml` now pins `nextest-version = "0.9.118"`, so an
older nextest fails with a clear version error instead of suspending
mid-run.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:02:56 -07:00
Maximilian Roos 612580b030 refactor(errors): finish routing command-failure bails through CommandError (#3483)
Follow-up to #3474: converts the remaining raw-output
`bail!("{stderr}")` sites to typed `CommandError`, so every
failed-command display goes through the one normalizing constructor
(`\r` stripping, canonical `✗ <command> failed (exit N)` header + output
gutter).

**Sites converted:**

- `step/shared.rs` (`git ls-files` in ignored-entry listing) and
`step/commit.rs` (`git add` into the temp index) — the two
commands-layer git sites.
- The five claude/codex plugin CLI calls in
`commands/config/{plugins,codex}.rs`, collapsed into one private
`run_plugin_cli` helper in `commands/config/mod.rs`. Their failure
snapshots now show the canonical header + stderr gutter instead of `✗
claude plugin marketplace add failed: <stderr>`.
- `execute_llm_command` in `llm.rs` — its hand-rolled
stderr/stdout/exit-code fallback ladder was `from_failed_output`'s job;
deleted in favor of the constructor. The `LlmCommandFailed` wrappers
read the detail via `ErrorExt::display_message()`, which extracts the
captured output for command failures and matches the old text in the
common cases; the picker summary pane guards on the error type
(`format_summary_error`) — command failures surface their captured
output, everything else keeps the full anyhow chain the pane always
showed (folded in from review). The shell program is recorded by
basename only (same rule as `render_llm_invocation`), so a no-output
failure doesn't leak the Git Bash install path on Windows.

**Deliberately left:** the two `gh pr list` / `glab mr list` bails in
`picker/prs.rs`. Their consumer is the skim-footer warning sink where
the current one-line text is the intended display, and picker text
sanitization was explicitly dropped (#3177).

**Behavior note** (intended, but visible): an LLM command that emits on
both streams before failing now shows both in the error block
(`combined_output()` joins stderr + stdout); the old ladder showed
stderr only.

**Testing:** three new error-path unit tests (`list_ignored_entries` /
`stage_to_temp_index` via corrupt-config and bad-pathspec,
`execute_llm_command` exit-code + basename assertion); the five plugin
sites are covered by the existing failing-mock integration tests whose
snapshots this updates; `test_generate_summary_llm_error` covers the
summary-pane command-failure arm and
`format_summary_error_keeps_chain_for_non_command_errors` the
chain-preserving arm.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:30:42 -07:00
Worktrunk Bot 9ed48fb32c fix(config): redirect misplaced nested keys to the other config with a scope note (#3473) 2026-07-15 02:22:01 -07:00
Worktrunk Bot 3b6630be4a fix(config): show reflects the #3461 object-store project-config fallback (#3477) 2026-07-15 02:21:05 -07:00
Worktrunk Bot d80cc80ebd feat(config): flag stale default-branch cache in wt config state (#3471) (#3478) 2026-07-15 02:18:55 -07:00
Maximilian Roos 489fd0f501 refactor(errors): route git-plumbing failure bails through CommandError (#3474)
Follow-up to #3459. Eight git-plumbing sites turned a captured non-zero
exit into `bail!("…{stderr}")`, bypassing
`CommandError::from_failed_output` — the one constructor that normalizes
`\r` and gives renderers the typed command/output split. This converts
them (`resolve_git_common_dir`, the bulk `config --list` read, `config
--unset`, `config --get-regexp`, `merge-tree`, `merge-base`, the
untracked-diff numstat, and `llm.rs::run_git_capture`), so
failed-command display routes through one constructor and the `main.rs`
multiline-error tripwire has nothing left to catch on these paths.
`from_failed_output` now takes `&[impl AsRef<str>]` so `Vec<String>` arg
lists work.

One structural fix rode along: `generate_commit_message` now wraps only
the `execute_llm_command` failure in `GitError::LlmCommandFailed`,
mirroring `generate_squash_message` — a git failure while building the
prompt is not an LLM-command failure, and flattening it into
`LlmCommandFailed.error` dropped the typed stderr.

Rendering: context-wrapped failures render byte-identically (the
outside-repo snapshots pass untouched); bare ones gain the standard `git
… failed (exit N)` header + gutter. The only snapshot changes are two
`wt list` task-failure lines where merge-base errors drop the
hand-rolled `git merge-base failed for <sha> <sha>:` prefix and now
match their sibling lines.

Every converted error path has a direct test — five new ones using
empirically-probed failure modes (invalid regexp pattern → exit 6,
invalid key → exit 1, corrupt config → 128 for the lazily-populated bulk
read and merge-tree, unborn HEAD for the numstat), plus the updated
`run_git_capture` unit test; `resolve_git_common_dir` and `merge_base`
were already covered by existing snapshots.

Two same-pattern sites remain in the commands layer (`step/shared.rs`
ls-files, `step/commit.rs` stage-to-temp-index) — deliberately left for
a follow-up along with the non-git CLIs (gh/glab, claude/codex).

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:42:26 -07:00
Maximilian Roos 934aad13cb fix(list): stop repeating the failure count in the list footer (#3467)
Follow-up to #3442. A failed task printed the same count twice on
adjacent lines — the summary footer's `N tasks failed` clause and the `▲
N tasks failed:` warning header directly below it that names each
failure:

```
○ Showing 5 worktrees, 3 ahead; 6 tasks failed
▲ 6 tasks failed:
  (detached): working-tree conflict check
    fatal: bad object HEAD
```

The footer clause was never a distinct signal: its count is exactly
`non_timeout_errors.len()`, and that vector being non-empty is precisely
the condition under which the warning renders. So the number was
duplicated in 100% of cases.

Rather than suppress the duplicate when a warning happens to follow,
this splits the two by what each reports. The footer counts what's
*missing* from the table — timed-out tasks, which leave an empty cell
and have no message to print, so a count is the only signal they can
carry (and the warning deliberately filters them out). Failures are
named in the warning with git's full error text.
`format_summary_message` drops its `error_count` parameter entirely; the
footer now reads:

```
○ Showing 5 worktrees, 3 ahead
▲ 6 tasks failed:
  ...
```

Also corrects a stale claim in the `status_symbols` module spec: it said
the footer "lists which tasks did not finish per item," giving a mapping
from a `·` cell back to the timed-out `TaskKind`. No such mapping ever
existed — the footer only carried counts, and budget-based truncation
produces no diagnostic at all by design.

One narrow behavior note: in progressive TTY mode the footer is stdout
(a repainted table row) while the warning is stderr, so `wt list
2>/dev/null` on a TTY no longer shows a bare failure count. That case
was already discarding the error detail, and discarding stderr discards
narration by definition; the ordinary piped case (both on stderr) is
unchanged.

## Testing

The `format_summary_message` unit test now covers the timeout-only
footer variants; the two failure-warning integration snapshots are
updated to show the deduplicated footer. Verified end-to-end against the
`index.lock` scenario (dirty worktree + pre-existing lock file).

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:17:17 -07:00
Maximilian Roos bb4abca109 fix: anchor relative WORKTRUNK_PROJECT_CONFIG_PATH to the worktree root (#3460)
`WORKTRUNK_PROJECT_CONFIG_PATH` was returned verbatim, so a relative
value resolved against the process cwd while the default
`.config/wt.toml` is anchored to the worktree root. Running `wt` from a
subdirectory made a relative override silently no-op (or read an
unintended file). This is the config-path footgun from #3454 (the RFC
there is a separate discussion).

A relative override now resolves against the same worktree root as the
default (current worktree, or the primary worktree at a bare root), so
it behaves identically from any subdirectory. Values that can't be
anchored fail loudly instead of guessing:

- relative with no worktree root to anchor to (bare repo with no linked
worktrees) errors
- Windows forms that are neither fully absolute nor relative
(drive-relative `C:cfg`, rooted-but-driveless `\cfg` or `/tmp/x`) error
rather than resolving against the process drive or cwd

Absolute values are unchanged (returned verbatim, no git calls), which
keeps the test-isolation use of the var working. An empty value still
means no project config, as before. `wt config show` (human format) and
`wt config update` previously folded a `project_config_path()` error
into "not in a git repository" / a silent skip; they now propagate it,
matching the JSON path.

Merging main brought in #3462's committed default-branch config fallback
for bare repos whose primary worktree is parked off the default branch.
That fallback now stands down when `WORKTRUNK_PROJECT_CONFIG_PATH` is
set: an explicit override names the config source outright (a missing
file there means no project config), and the override exists for test
isolation, where reading the repo's own committed config is exactly the
leak being prevented.

The human format of `wt config show` now reports "No project config"
when no location exists (bare repo with no worktrees, empty override)
instead of the inaccurate "Not in a git repository", which stays
reserved for actually being outside a repo.

Compatibility: invoked from the worktree root, cwd and root coincide, so
old and new resolution agree. From a subdirectory the old resolution
found nothing, which is the bug being fixed, so no deprecation shim is
included.

Thanks @indexzero for reporting in #3454.

🤖 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-14 10:45:22 -07:00
Worktrunk Bot 5c6fbed809 fix: resolve default-branch project config by name, not cwd HEAD (#3466)
Follow-up to #3462 (merged). That PR's final commit changed the
object-store fallback to read `git show HEAD:.config/wt.toml`, and this
reintroduces #3461 in the common agent case.

## The bug

`git show` resolves `HEAD` against the **invocation cwd's per-worktree
HEAD**, and the read runs with `discovery_path` (the cwd `wt` was
invoked from) as its working directory. When `wt` runs from *inside a
linked worktree parked on another branch* — exactly the agent-driven
workflow the #3461 fix targets — `HEAD` is that worktree's branch, not
the default branch. So the fallback either reads the wrong branch's
config or, when the invoking worktree's branch carries no committed
config, drops the default branch's config and every project hook.

The merged PR's two regression tests only invoke `wt` from the **bare
root**, where `HEAD` does point at the default branch, so they can't
catch this.

## Verified end-to-end

Bare repo; default branch `main` has `.config/wt.toml` with a
`post-start` hook; `main` checked out in no worktree; a second worktree
`other` whose branch dropped the config on disk:

| Invoked from | `git show HEAD:.config/wt.toml` | Hook fires? |
|---|---|---|
| bare root | reads `main`'s config | ✅ (what the merged tests cover) |
| `other` worktree | reads `other`'s HEAD → no config | ❌
**default-branch config dropped** |

The absolute ref `git show main:.config/wt.toml` reads the config
correctly from any cwd — which is what the pre-regression commit (and
@max-sixty's original "load it in with git show" request on #3462) did.

## The fix

Resolve the default branch **by name**
(`<default-branch>:.config/wt.toml`), which is cwd-independent. This is
a one-line change back to an absolute ref, plus a doc-comment
correction.

The added regression test invokes `wt switch --create` from inside the
`other` worktree and asserts the default branch's hook fires. It fails
against the merged `HEAD:` behavior (marker never written) and passes
with the fix; the two existing #3461 tests stay green.

## Coverage note

`self.default_branch()?` restores an early-`None` return when the
default branch can't be determined (bare repo with no resolvable
default). That branch is a defensive guard and isn't exercised by the
tests — the `HEAD` form avoided it, which is likely why it was
introduced, but correctness outranks covering a one-line
unreachable-in-practice guard. Flagging in case `codecov/patch` trips on
it.

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-14 10:19:28 -07:00
Worktrunk Bot f2ecccbabe docs(testing): correct TEST_EPOCH date to 2025-01-02 (#3463) 2026-07-14 00:56:57 -07:00
Worktrunk Bot f66b49e348 fix: resolve project config from bare root when primary worktree is off default branch (#3462) 2026-07-14 00:56:03 -07:00
Maximilian Roos a6972e4be0 fix(shell): detect the current shell from the process tree, not $SHELL (#3455)
`wt switch` could tell a fish user "shell requires restart" because
`$SHELL` (the login shell, say zsh) had the integration line while the
shell actually in use didn't. `current_shell()` keyed everything on
`$SHELL`, which names the login shell rather than the shell wt was
invoked from.

**Process-tree detection.** `shell::ancestor_shell()`
(src/shell/utils.rs) walks up from wt's parent to the nearest enclosing
shell: Linux reads `/proc/<pid>/stat`; macOS parses one `ps -Ao
pid=,ppid=,comm=` snapshot. macOS cannot use per-pid `p_comm`
(libproc/sysctl): `/bin/sh` re-execs bash via `/var/select/sh`, so an sh
script's `p_comm` reads "bash", while `ps`'s comm preserves argv[0].
Verified live and pinned by `test_probe_reports_invoked_name_for_sh`.
The walk passes through non-shell wrappers (`git`, `sudo`) and plain
script interpreters (`sh`, `dash`); a known-but-unsupported interactive
shell (tcsh, ksh) stops the walk so a shell further up can't claim the
session. `current_shell()`/`current_shell_name()` consult the walk first
and fall back to `$SHELL` / `PSModulePath` (Windows behavior unchanged;
Git Bash sets `$SHELL` itself).

**Hedged messaging.** The reason string is now "shell integration
installed but not active", and the hint presents restart as the likely
fix with an escape hatch: "A shell restart usually activates shell
integration; if it doesn't, ask an agent to debug with the docs @
https://worktrunk.dev/llms.txt" (the LLM docs index works for any agent;
the `/worktrunk` skill exists only for plugin users). Post-install hints
are unchanged (restart advice there is unconditional by construction).
`wt config show` now prints `Detected shell: <name> (process tree)` next
to `$SHELL` in the diagnostic gutter.

**Also fixed along the way:** `shell_from_name` requires a word
boundary, so `fishd`/`bashtop`/`numactl` no longer classify as shells
(`numactl` previously matched Nushell via the bare `nu` prefix); the
post-install/uninstall restart hints compare `Shell` values instead of
display strings, which never matched process-tree names like `pwsh` or
`zsh-5.9`.

**Testing.** Integration tests spawn wt under a test harness whose real
ancestry (nextest, cargo, the developer's shell) would leak in, so
`WORKTRUNK_TEST_PARENT_SHELL` is pinned empty in `STATIC_TEST_ENV_VARS`
(and in the two PTY helpers that mirror it by hand, which CI round 1
caught: locally the walk found the dev zsh, on CI the runner's bash) and
set per-test to simulate ancestors. New integration tests cover the
three detection outcomes (mismatch suppresses restart advice, configured
ancestor shows the hedged hint, unsupported ancestor overrides
`$SHELL`); unit tests cover the classifier, the walk itself (injectable
process-table lookup: wrapper pass-through, transparency,
cycle/depth/init terminations), and both OS probes against live
processes; `wt config show` is snapshot-tested with an override ancestor
and run once unpinned so the production walk executes end-to-end.

Known limits, accepted: the walk stops at the nearest supported shell
even when it's a non-interactive script interpreter (`bash -c` wrapping
wt reports bash), hence the hedged wording; the walk is bounded at 16
hops and any unreadable hop falls back to `$SHELL`.

> _This was written by Claude Code on behalf of max_

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:33:55 -07:00
Maximilian Roos 36ba57bdca fix(plugin): ship skills to Codex installs via a generated real-file mirror (#3440)
Codex installs of the worktrunk plugin carried no skills. `codex plugin
add` copies the plugin into `$CODEX_HOME/plugins/cache/` via
`copy_dir_recursive` (codex-rs core-plugins), which handles only regular
files and directories — so the `skills -> ../../skills` symlink (and the
nested `reference/README.md` link inside the tree) were silently
dropped, and sessions load from that cache copy. Verified against
codex-cli 0.144.1 both by reading the tagged sources and by installing
this repo's plugin into a scratch `CODEX_HOME`: the installed root had
no `skills/` at all and `codex debug prompt-input` showed an empty
plugin skill inventory.

This PR cuts the plugin's `skills` symlink over to a **generated
real-file mirror** of the authored repo-root `skills/`, kept current by
a new `sync_plugin_skills_mirror` stage in `test_docs_are_in_sync`
(dereferences symlinks, deletes stale files, self-heals and fails on
drift — same pattern as the other generated mirrors). Repo-root
`skills/` stays the authored home: Gemini hard-probes it at the
extension root, the docs sync writes into it, and Windows checkouts read
it. Reversing the symlink direction instead would put a symlink at the
repo root, breaking Gemini's install copy and every Windows checkout —
which also surfaces a latent bug this fixes: symlinks materialize as
plain text files on Windows clones, so installs from a Windows checkout
shipped no skills to Claude or Codex either.

It also drops the Codex manifest's `skills: "./skills/"` key: with no
key, Codex scans `<plugin-root>/skills/` by convention
(`default_skill_roots` in `codex-rs/core-plugins/src/loader.rs`), and
the explicit path resolves to the same directory, so the key was
redundant — symmetric with the Claude manifest cutover in #3431.

**For the reviewer:**

- The 20 files under `plugins/worktrunk/skills/` are the generated
mirror (byte-identical to repo-root `skills/`; git stores shared blobs
once). `plugins/worktrunk/CLAUDE.md` → "Plugin skills are a generated
mirror" documents the rationale with codex-rs citations.
- `sync_plugin_skills_mirror` in
`tests/integration_tests/readme_sync.rs` is the sync stage (Step 3b of
the pipeline); it already proved itself once in this branch — merging
main regenerated `reference/{config,list}.md` in the mirror.
- `test_plugin_layout_is_consolidated` now pins the mirror shape
cross-platform (real directory, no symlinks anywhere under it),
replacing the unix-only symlink assertion.

**Verification:** fresh scratch-`CODEX_HOME` install now carries both
skills into the cache and `codex debug prompt-input` lists `worktrunk`
and `wt-switch-create`; `claude plugin validate` passes and `claude
--plugin-dir … plugin details` discovers Skills (2) plus all 8 hooks;
Gemini's repo-root path is untouched. A 2×2 probe matrix (manifest key
present/absent × real dir/symlink) confirmed key-absence changes nothing
and symlinks ship nothing.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:48:30 -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 d12c55a320 feat(config): wt config update adopts json-schema = 2 instead of pinning 1 (#3436)
Flips the direction of the `[list] json-schema` pending-default handling
that #3411 introduced: `wt config update` now writes `json-schema = 2`
(adopting the upcoming default) instead of pinning `= 1` (preserving the
current one). Running update is the migration; staying on schema 1 is
the deliberate manual edit.

The `wt list --format=json` nag flips to match, keeping the adopt action
last for easy copying:

```
▲ JSON output is schema 1; a future release switches the default to schema 2
↳ To keep this format set [list] json-schema = 1; to adopt the new schema, run wt config update
```

Why: with pin-to-1, every `wt config update` run during the deprecation
window entrenched users on the schema being retired, leaving a pinned
cohort the default flip could never migrate. With adopt-2, update moves
users forward as a reviewed config edit, and after the flip `= 2` is
just a redundant default a future rule can strip. The trade: `wt config
update --yes` in a script switches JSON output as a side effect of
unrelated migrations (the interactive path shows the diff first).

The system-config gate is unchanged — when the system layer defines the
key, update leaves the user file alone; the test now covers the sharper
direction (system `= 1` must not be overridden by a user-file `= 2`).
All detection/warning invariants carry over; the diff is ~6 semantic
lines plus pin→adopt wording and 65 one-line snapshot flips.

🤖 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-12 15:34:48 -07:00
Maximilian Roos e02453aa5f feat(list): demote branch-worktree mismatch from red flag to dim info (#3419)
Implements option 3 ("demote the presentation") from the design proposal
in #3415, for #3389: worktrees created by agent harnesses at their own
conventional paths (`.claude/worktrees/…`) made every `wt list` row
carry a red `⚑` at merge-conflict severity.

The list predicate, JSON fields, and Path-column behavior are unchanged;
only the presentation changes:

- `⚑` renders dim yellow (was red) in `wt list`, the picker, and the
`--help` legend: below the full-yellow actionable states (prunable,
locked), above plain dim.
- Priority within the status slot becomes `✘ > ⤴ > ⤵ > ⊟ > ⊞ > ⚑ > /`:
the yellow actionable states (prunable, locked) now outrank the
informational glyph instead of being masked by it. The JSON schema-1
`state` field follows the same priority; schema-2 booleans are
independent and unaffected.
- The inline notice on `wt switch` / `wt remove` / `wt merge` / `wt step
prune` is gone entirely: the `wt list` glyph is the state's one surface.
The `expected_path` plumbing is deleted end to end, so switching to an
existing worktree no longer expands the `worktree-path` template at all
(it only ran to feed the notice).

Before/after on a repo with agent worktrees:

```
+ claude/frosty-kilby-92c7d3     ⚑_        (red ⚑, reads as an error)
+ claude/frosty-kilby-92c7d3     ⚑_        (dim-yellow ⚑, reads as a note)
```

Option 1 from the design doc (narrowing the predicate so only genuine
collisions flag at all) can land separately on top of this.

Well-tested: the pre-merge gate passes (4390 tests), snapshots reviewed
line-by-line (glyph color and priority changes in list/help output; the
notice lines removed from switch/remove output), the removed-notice
tests renamed to pin the silence, and clippy is clean including
`--features shell-integration-tests`. Verified live: `wt list` shows the
dim-yellow flag; `wt switch`/`wt remove` print nothing about the
mismatch.

Thanks to @dmsmidt for the report in #3389.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:27:17 -07:00
Maximilian Roos b8134563f1 fix(list): unify task-failure output vocabulary (#3435)
Reviewing this real `wt list` output against the `writing-user-outputs`
guidelines surfaced several inconsistencies:

```
○ Showing 15 worktrees, 6 branches, 2 with changes, 8 ahead, 2 columns hidden. 1 task failed
▲ Some git operations failed:
  plugins: working-tree-conflicts (fatal: Unable to create
  '/Users/…/.git/worktrees/worktrunk.plugins/index.lock': File exists.)
↳ To create a diagnostic file, run with -vv
```

The footer and the warning used different vocabulary for the same
failure ("task" vs "git operations", the latter inaccurate since
CI/URL/summary tasks aren't git operations), the warning said "Some"
when the exact count was known, and the per-failure lines leaked
internal kebab-case `TaskKind` discriminants (`working-tree-conflicts`)
that appear nowhere in docs or help.

The same output now reads:

```
○ Showing 15 worktrees, 6 branches, 2 with changes, 8 ahead, 2 columns hidden; 1 task failed
▲ 1 task failed:
  plugins: working-tree conflict check (fatal: Unable to create '…/index.lock': File exists.)
↳ To create a diagnostic file, run with -vv
```

## Changes

- **One term, matching counts.** Footer and warning both say "N task(s)
failed" ("task" is the documented term — the JSON docs already use it).
The mixed form becomes disjoint (`2 tasks failed, 3 timed out` instead
of `5 tasks failed (3 timed out)`), so the footer's "failed" count
always equals the number of lines in the warning below it.
- **Human task names.** `TaskKind::display_name()` maps each task to
user vocabulary (`working-tree conflict check`, `ahead/behind counts`,
`CI status`, …) in the failure warning, the stall footer ("waiting on
…"), and the drain-timeout diagnostic. The strum kebab-case form remains
for tracing and `-vv` diagnostics.
- **Footer clause join.** Semicolon instead of a period splice, per
house style; also fixes always-plural "1 worktrees"/"1 branches" in
`--branches` mode.
- **Buffered summary to stderr.** The `○ Showing …` line narrates the
table rather than being part of the answer (`--format=json` omits it),
so in buffered mode it moves to stderr and piped stdout ends cleanly
after the last row. The progressive path keeps it on stdout, where the
summary is a repainted row of the table region. This is most of the
198-file snapshot churn (the line moves from the stdout section to the
stderr section).
- **Docs generator reads both streams.** `parse_snapshot_raw` in
readme_sync previously embedded stderr *or* stdout; it now concatenates
both in terminal order. The generated docs are byte-identical before and
after the stream move, which is the check that the console blocks still
read like the terminal.

Two negative test assertions that guarded against surfacing these
failures (`working-tree-conflicts` in `tests/integration_tests/list.rs`)
were retargeted to the new display names so they don't pass vacuously.

## Testing

Pre-merge gate (all tests + lints) green; feature-gated clippy and the
PTY progressive-list tests (`shell-integration-tests`) run separately
and green, since the local gate skips them.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:17:30 -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 6c122a5d08 refactor(plugin): adopt Claude's canonical manifest layout, convention-only discovery (#3431)
## What this does

Moves the Claude plugin manifest to the canonical
`plugins/worktrunk/.claude-plugin/plugin.json` and strips it to metadata
only, dropping the `hooks` and `skills` keys so that both component
types load purely by Claude Code's convention discovery. Rewrites the
layout docs and inverts `test_plugin_layout_is_consolidated` to pin the
new invariants.

## Background

The plugin layout has been in flux:
[#3382](https://github.com/max-sixty/worktrunk/pull/3382) Claude-scoped
the hooks filename, which silently broke hook loading
([#3417](https://github.com/max-sixty/worktrunk/issues/3417)), and
[#3418](https://github.com/max-sixty/worktrunk/pull/3418) reverted it.
That revert restored working hooks but left two claims baked into the
layout, the docs, and the test that this PR found to be stale.

## Research method

Everything below was verified empirically against claude-cli 2.1.207
(the current release), not from docs alone:

- **Scratch marketplace probe**: a throwaway marketplace mirroring
worktrunk's exact shape (repo-root `.claude-plugin/marketplace.json`,
plugin in a `./plugins/<name>` subdir source, `skills` as a symlink
pointing outside the plugin dir, hooks at the conventional path) with
two plugin variants — manifest in `.claude-plugin/plugin.json` vs. bare
`plugin.json` at the plugin root — installed through the real `claude
plugin marketplace add` + `claude plugin install` flow.
- **Component inventory**: `claude plugin details` and `claude plugin
validate` on both variants.
- **Live sessions**: headless `claude -p` runs confirming skills appear
in-session and hook commands actually execute (marker files, and reading
the 🤖 activity marker from inside a live session).
- **Loader inspection**: the manifest resolution order read directly out
of the claude-code binary's bundled loader code.

## Findings

1. **The "no `.claude-plugin/` wrapper" claim no longer holds.** The
repo documented — and the test pinned — that Claude wants `plugin.json`
bare at the plugin root, and that a `.claude-plugin/` wrapper in a
subdir-source plugin fails with "Plugin not found". On 2.1.207 the
wrapper form installs, loads skills, and fires hooks end-to-end. The
loader tries `.claude-plugin/plugin.json` first with root `plugin.json`
only as a runtime fallback, several marketplace-resolution paths read
*only* the wrapper, and `claude plugin validate` rejects the root-level
form outright ("No manifest found in directory. Expected
.claude-plugin/marketplace.json or .claude-plugin/plugin.json").
2. **"Claude has no skill auto-discovery" is wrong.** Skills are
auto-discovered by scanning `skills/*/SKILL.md`; a `skills` manifest
array only *adds* directories to that scan (entries equal to the default
dir are filtered). An unlisted skill behind the out-of-plugin symlink
loaded fine — the installer dereferences the symlink into a real
directory in the install cache. Dropping the array also makes the old
test guard ("every skill must be listed or it's silently invisible")
structurally unnecessary: a new repo-root skill now ships to Claude,
Codex, and Gemini with no manifest change anywhere.
3. **The `hooks` manifest key was dead config.** #3417 established the
string-path override isn't honored for plugin loads; convention
discovery of `hooks/hooks.json` is the mechanism that works. A key
pointing at the conventional path adds nothing and could mask a future
mislocated file, so it's gone (one mechanism per guarantee).

## Changes

- `plugins/worktrunk/plugin.json` →
`plugins/worktrunk/.claude-plugin/plugin.json`; content reduced to
`name`/`description`/`author`.
- `plugins/worktrunk/CLAUDE.md`: layout diagram and Claude
path-resolution bullet rewritten to the convention-only model; the
refuted wrapper claim and the stale no-auto-discovery claim removed.
- Root `CLAUDE.md`: the Plugin Layout pointer updated (dropped the stale
"Codex no-hooks re-enablement" phrase — Codex hooks shipped inline in
#3364).
- `test_plugin_layout_is_consolidated`: now asserts the wrapper manifest
exists, a root-level `plugin.json` does not, the manifest carries no
`hooks`/`skills` keys, the plugin `skills` symlink resolves to the
repo-root `skills/` (unix only — Windows checkouts without
`core.symlinks` materialize it as a file), and every repo-root skill dir
carries a `SKILL.md` (the scan silently ignores a dir without one).

Codex and Gemini are untouched: Codex reads only its `.codex-plugin/`
wrapper, and its inline `hooks` object still overrides Codex's own
convention discovery, so the #3362 collision stays closed independent of
any of this.

## Verification

- `claude plugin validate plugins/worktrunk` passes (it failed on the
old layout). Remaining warnings are deliberate: no `version` field
(installs pin the git SHA as the version; a hand-maintained semver would
go stale) and the plugin-root `CLAUDE.md` (developer docs, not plugin
context).
- A live `claude --plugin-dir` session on this exact tree loads both
skills and the `UserPromptSubmit` hook sets the 🤖 marker, observed from
inside the session.
- The scratch-marketplace probe covered the same shape through the full
marketplace install flow, including the symlinked skills dir.
- Full pre-merge gate: 4389 tests passed plus lints, doctests, and
docs-sync.

## Risk

The one scenario this could regress is a Claude Code version old enough
to read *only* the root-level manifest. That couldn't be tested
directly; the mitigations are that the wrapper is the documented
convention, the loader prefers it today, and Claude Code self-updates
aggressively. The old layout's "verified end-to-end" claim most likely
dates from an earlier 2.1.x behavior or a misdiagnosed failure.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:35:51 -07:00
Worktrunk Bot 9cb3693fdf test(switch_picker): tolerate Windows self-exit-1 on successful picker-create (#3427) 2026-07-12 08:18:01 -07:00
Worktrunk Bot 81031bdd2a chore: bump MSRV and toolchain to 1.96 (#3428) 2026-07-12 08:16:09 -07:00
Worktrunk Bot c532d2dabd docs(llm-commits): bump Codex commit-generation model to gpt-5.6-luna (#3430) 2026-07-12 08:15:26 -07:00
Chris Chen 0b42d42670 feat(step promote): add --format text|json flag (parity with rebase/push/etc.) (#3424) 2026-07-12 02:01:05 -07:00
Worktrunk Bot eba35d039c fix(shell): stop PowerShell wrapper leaking a stray exit-code line to stdout (#3425) 2026-07-12 01:54:55 -07:00
Worktrunk Bot 69d6afb1fe fix: restore Claude plugin hooks to conventional hooks/hooks.json (#3418)
## Problem

Since [#3382](https://github.com/max-sixty/worktrunk/pull/3382)
("Claude-scope the shared hooks payload"), the Claude plugin's hooks
stopped loading in Claude Code. Skills and commands still load, so the
plugin looks healthy — but `/hooks` shows none of worktrunk's handlers
and the 🤖/💬 activity markers never update. It fails silently.

Root cause (confirmed by the reporter on Claude Code 2.1.207): Claude
Code discovers plugin hooks by **convention** at `hooks/hooks.json`. It
does **not** honor `plugin.json`'s string-path `"hooks":
"./hooks/claude-hooks.json"` override for plugin loads. The #3382 rename
moved the file off the only path Claude's loader actually reads.

The rename was intended to stop Codex's convention discovery from
surfacing Claude's events
([#3362](https://github.com/max-sixty/worktrunk/issues/3362)). But the
Codex manifest already defined its hooks **inline** (`Some(Inline)`
branch of `resolve_manifest_hooks`), which overrides Codex's convention
discovery — and that inline definition **predates** the rename (verified
against `2677f05^`). So the rename was always redundant for the Codex
scoping, while it broke Claude's discovery.

## Solution

Restore the file to the conventional
`plugins/worktrunk/hooks/hooks.json` and point `plugin.json`'s `hooks`
back at it. `plugin.json` reverts byte-for-byte to its pre-#3382 state.

This does not resurface the #3362 collision: the Codex manifest's inline
`hooks` object keeps Codex off the shared `hooks/hooks.json` regardless
of the filename. The two toolchains now coexist on one file — Claude
discovers it, Codex ignores it via the inline override. The
`test_plugin_layout_is_consolidated` assertions covering the Codex
inline manifest are unchanged and still pass.

Also updates `plugins/worktrunk/CLAUDE.md` (layout diagram + Known
Limitations) and drops the now-reverted #3382 CHANGELOG entry, replacing
it with a Fixed entry (both were in the unreleased 0.67.0, so the net
change is nil).

## Testing

Reproduction is captured in `test_plugin_layout_is_consolidated`
(`tests/integration_tests/config_show.rs`), whose invariant was inverted
from "the Claude file must be Claude-scoped" to "the Claude file must
sit at the conventional `hooks/hooks.json` that Claude Code's loader
discovers." Against pre-fix production it fails (`left:
"./hooks/claude-hooks.json"`); after the rename it passes.

- `test_plugin_layout_is_consolidated` ✅
- `test_claude_hook_commands_parse_in_all_shells` ✅ (reads the renamed
file; shell-integration-tests)
- Full `integration_tests::config_show` module: 136 passed ✅
- `test_docs_are_in_sync` ✅

**Not verified end-to-end from CI** (per running-tend "Don't ship fixes
you can't verify"): the bot cannot drive a live Claude Code or Codex
session to observe the hooks firing. The fix rests on the reporter's
confirmed symptom + repro (symlinking `hooks/hooks.json` →
`claude-hooks.json` immediately restores the hooks) and on Claude Code's
[documented convention
path](https://code.claude.com/docs/en/plugins-reference.md)
(`hooks/hooks.json` in plugin root). The Codex-stays-clean claim rests
on the existing code analysis in CLAUDE.md and the inline manifest
predating the rename, not a live Codex run.

---
Closes #3417 — automated triage

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-10 20:37:21 -07:00
Worktrunk Bot bd504f0998 feat(switch): run --execute against the picked worktree (#3394) 2026-07-10 04:30:14 -07:00
Maximilian Roos dac0f80608 feat(config): wt config update pins [list] json-schema while unset (#3411)
Wires the `[list] json-schema` nag into the standard config-update
machinery, so the warning comes with the standard one-command fix.

`wt config update` now pins `json-schema = 1` (the behavior-preserving
choice) when the key is unset, via a new `PendingDefault` variant in
`DEPRECATION_RULES`: applied on the update pass only — the load path
must not pin, or an in-memory `Some(1)` would silence the nag — and
scoped to user config through a `ConfigFileKind` enum threaded through
detection in place of the old string labels. Two guards keep the pin
honest: it stays inert when the system config layer already defines the
key (a user-file pin would override a system-level `= 2` and flip
resolved output), and the nag's hint offers `wt config update` only when
the same detection update runs would actually write the pin — a missing,
unreadable, or malformed user config falls back to naming the manual
setting.

The detection-equals-migration invariant holds with the warning
relocated: the pin's warning fires at the JSON-emitting surface
(`resolve_json_schema`) exactly when update would change the file, while
config load stays quiet (`DeprecationKind::is_pending_default` filters
it, and pin-only configs skip the warning-dedup machinery entirely), so
`wt switch` users never see it. `wt config show` renders the pending
pin's diff — including for empty config files — but keeps its TOML dump:
a pin is additive, unlike a deprecation diff that supersedes the dump.

This departs from the plan reviewed in `design/list-json-v2.md` (#3357),
which deferred the `wt config update` integration to the default flip.
Deliberate tradeoff: users who run update during the window land pinned
on schema 1 and will see the `= 1` deprecation round after the flip; in
exchange, the warning ships with its fixer.

**Testing:** unit tests pin the rule's iff (unset ⟺ update changes the
file), kind scoping (System/Project inert, load pass inert), the
PendingDefault-rule/kind coupling, and placement in existing or implicit
`[list]` sections; integration tests cover both hint variants, the
update flow end-to-end (pin applied, second run clean), `--print`, and
the system-config deferral. The invariant battery runs with an explicit
pin appended so each case exercises only its own rule. Full pre-merge
gate green (4386 tests) plus `--features shell-integration-tests`
clippy.

🤖 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-10 03:56:40 -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 57b303fda2 fix(nix): provide lsof to the flake's test sandbox (#3410)
The nix-flake job has failed on main since #3396:
`test_remove_reap_kills_process` discovers its child via `lsof -d cwd`,
but the flake's `worktrunk-tests` check didn't provide `lsof`, so
`processes_under` fails to spawn it, returns an empty list forever, and
the test's 5s discovery poll panics with `child <pid> never discovered`.
Failing main run:
https://github.com/max-sixty/worktrunk/actions/runs/29066326142.

Three changes:

- `flake.nix`: add `pkgs.lsof` to the test check's inputs, the same
pattern as `git`/`python3`/`procps`. `ps` (procps) already works in the
sandbox, so /proc-based process inspection is available there and the
test should be genuinely exercised rather than excluded.
- `.github/workflows/nightly.yaml`: add `flake.nix`, `flake.lock`, and
`nix/**` to the trigger paths (push and PR gate). A flake change
couldn't previously trigger the nix-flake job that validates it; this PR
relies on the fixed trigger for its own validation.
- `tests/integration_tests/remove.rs`: point the discovery-timeout panic
message at lsof, so the next environment missing it doesn't need
re-diagnosing.

Validation: nix isn't available locally, so the proof is this PR's own
nightly run going green on the nix-flake job.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:29:40 -07:00
Maximilian Roos 0b413ae3b2 fix(list): reserve prompt rows so the table doesn't jump at exit (#3409)
`wt list`'s progressive table sits stable on screen through the loading
phase, then jumps up at an unpredictable moment: the command exits and
the shell prints its multi-line prompt, and the terminal scrolls to make
room. Every other command scrolls too, but there the output scroll and
the prompt scroll merge into one Enter-time event; the progressive
table's delay between paint and exit is what makes the jump read as a
jerk.

The renderer now emits two blank rows below the footer with the skeleton
and moves the cursor back over them. The prompt renders into those
pre-scrolled rows, so the settled table doesn't move at exit; the scroll
happens at Enter-time with the skeleton paint, where scroll is expected.

The exact prompt height is unknowable, but the failure modes are
asymmetric, so a constant works: extra reserved rows are consumed
invisibly by whatever prints next (verified in tmux: a 1-line prompt
leaves scrollback byte-identical to before), while a prompt taller than
3 rows scrolls by just the difference. Two rows absorb fish's default
(1), starship's default (2), and tide/powerlevel10k (typically 3).

Details:

- The viewport budget goes from `h − 4` to `h − 6` so a bottom-pinned
skeleton plus reserve can't scroll its own header off, which would break
the `MoveUp` redraw math. Short terminals show two fewer skeleton rows;
the overflow finalize still prints the full table at the end.
- The overflow finalize path re-emits the reserve after its reprint.
- The resting cursor position (line after the footer) is unchanged, so
the in-place update code needed no changes.
- The PTY test harness now exposes the final cursor position; the
overflow test asserts the cursor rests two reserved rows above the
bottom, and the fast-command test pins the resting position (catching an
emit-without-`MoveUp` mismatch).

Verified end-to-end in tmux (fish, 3-row prompt, full screen, 11
worktrees): the header row stays fixed across run, exit, and prompt,
where the released binary jumps two rows. The error path (post-table
error blocks printed after finalize) consumes the reserve and keeps
today's behavior, which is fine: new content appearing at completion
legitimately grows the screen.

🤖 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-09 22:35:47 -07:00
Maximilian Roos 958b3082de fix(env): scrub GIT_* discovery vars from for-each and --execute fallback (#3400)
#3374 scrubbed inherited `GIT_*` discovery vars from user-hook spawns,
but two more spawn sites relocate a user command into a wt-chosen
worktree and had the same bug: `wt step for-each` and the `--execute`
payload when wt executes it directly (no shell integration — including
every `!wt`-alias invocation, which bypasses the wrapper). Git resolves
`GIT_DIR`/`GIT_WORK_TREE` before walking up from the cwd, so the
inherited value silently overrode the worktree wt placed the command in:
`git w step for-each -- git rev-parse --show-toplevel` from a linked
worktree printed the invoking worktree's path for every iteration while
the headers named each worktree. The linked-worktree alias case is the
common trigger — git exports an absolute `GIT_DIR` pinned to the
invoking worktree's private gitdir there (verified on git 2.54; from a
main-worktree root it exports none).

The rule is now stated once, on `scrub_git_discovery_env_vars`: **scrub
exactly when wt chose the child's cwd** (hooks, for-each, the
`--execute` fallback); keep when the command runs in the user's own
context (aliases, `commit.generation`, and wt's internal plumbing, which
keeps the absolutize-and-forward behavior from #1914). A CLAUDE.md rule
points there, mirroring the `CommandTrace` any-new-spawn-site
convention.

Testing: regression tests for both new scrub sites (each validated by
reverting the fix and watching it fail) plus an alias pass-through test
pinning the keep side. The `--execute` test is cross-platform, so
Windows CI drives the non-unix `spawn` variant and unix the `exec`
variant; the for-each test follows the file's unix `sh -c` precedent.

Ref #3373 (already closed by #3374; this completes the sweep).

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:08:33 -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
Worktrunk Bot 1d46c0083b fix(test): treat Linux PTY EIO-on-close as EOF in read helpers (#3398)
## Problem

The `code-coverage` job failed on [run
29053905721](https://github.com/max-sixty/worktrunk/actions/runs/29053905721)
(default branch, commit f593438) with a panic in a PTY test:

```
thread '...test_branch_name_with_dashes_underscores::case_3' panicked at tests/common/pty.rs:44:41:
```

Line 44 was `reader.read_to_string(&mut buf).unwrap()` in the Unix
branch of `read_pty_output`. Only `case_3` (fish) panicked;
`case_1`/`case_2` (bash/zsh) passed in the same run — the flaky
signature.

**Root cause:** On Linux, a `read` on a PTY master returns `EIO` once
the child exits and closes the slave side, instead of the clean 0-byte
EOF macOS returns. `read_to_string` propagates that `EIO` as an
`io::Error`, and `.unwrap()` panics. Whether the read observes the `EIO`
or a clean EOF depends on whether it raced ahead of or behind the
child's exit — hence the intermittent, per-case failure.

This is the Linux face of the same fragile read that #3144 diagnosed as
a macOS read-to-EOF timeout; that issue explicitly flagged a more robust
read in this path as the escalation if the flake recurred.

## Solution

Extract a shared `read_pty_master_to_string` helper that reads to
end-of-stream and treats `EIO` as EOF on Unix (a plain `read_to_string`
on Windows/ConPTY, behavior unchanged). Route both PTY-master reads that
shared the fragile `read_to_string().unwrap()` pattern through it:

- `read_pty_output` (`tests/common/pty.rs`) — the shell-wrapper and
README-example PTY path.
- `execute_shell_script` (`tests/common/shell.rs:108`) — the e2e-shell
path, which had the identical pattern and the same latent flake.

`libc` (already present transitively via `portable-pty`) is added to
`[dev-dependencies]` for the canonical `EIO` constant.

## Testing

- `cargo test --test integration --features shell-integration-tests` for
`test_branch_name_with_dashes_underscores` and
`test_source_flag_forwards_errors` (bash/zsh/fish cases) — all pass.
`case_4` (nu) is only skippable locally because nushell isn't installed
in the CI-fix sandbox; it fails at *spawn*, not the read path, and
passed in the original CI run.
- `e2e_shell::*` (7 tests exercising `execute_shell_script`) — all pass.
- `cargo clippy --tests --features shell-integration-tests` and `cargo
fmt --check` — clean.

---
Automated fix for [failed
run](https://github.com/max-sixty/worktrunk/actions/runs/29053905721)

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-09 19:42:48 -07:00