Commit Graph

102 Commits

Author SHA1 Message Date
Maximilian Roos 2d4b6d8ac1 docs(shell): state that install owns the wrapper path it writes (#3821)
`wt config shell install` replaces an existing `functions/{cmd}.fish`,
`completions/{cmd}.fish`, or `vendor/autoload/{cmd}.nu` whole. That is
the design — the path is named after the command, so it names the file
worktrunk owns — but neither the write site nor the FAQ's file inventory
said so, which leaves the overwrite reading as a missing ownership
check.

`is_worktrunk_managed_content` already carries the rule, for the
uninstall side that has to recognize a file without knowing its name. So
`configure_wrapper_file` states it in a clause and points there rather
than keeping a second copy. The FAQ sentence adds the contrast: rc files
hold the rest of a shell's setup, so install only appends to those.

No behavior change, and no ownership check added.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:15:46 -07:00
Maximilian Roos 7aba380f0c fix(remove): gate removal on the registration, not the repository (#3808)
`wt remove --force` deleted a live worktree of this same repository,
uncommitted work included, whenever that worktree had been moved onto
another worktree's registered path.

The guard added in #3785 asks which *repository* the occupant answers
to: a linked worktree's git dir sits under `<common>/worktrees/`, the
main worktree's *is* the common dir, anything else is someone else's. A
sibling worktree moved onto the path satisfies that — its git dir sits
under `<common>/worktrees/` like any worktree of this repo — so it
passed, and the fast path renamed the directory into trash and handed
the `rm -rf` to a detached process. It is not prunable either: its
gitdir file points at a location that exists, so the `is_prunable` arm
from the same PR doesn't catch it.

Git's own validation is one level finer. `validate_worktree` requires
the directory to point back at *this registration*, and refuses this
removal with `--force`:

```console
$ git -C repo worktree remove --force ../repo.feature
fatal: validation failed, cannot remove working tree:
  '.../repo.feature' does not point back to '.git/worktrees/repo.feature'
```

<details>
<summary>Reproducer, verified against a build of main</summary>

The occupant has to be *moved* onto the path rather than created there —
`git worktree add` refuses a registered path, which is what leaves a
plain `mv` as the way this state arises.

```console
$ git -C repo worktree add ../repo.feature -b feature
$ git -C repo worktree add ../repo.bar -b bar
$ rm -rf ../repo.feature && mv ../repo.bar ../repo.feature
$ echo PRECIOUS > ../repo.feature/precious.txt
$ wt remove --force --yes feature
◎ Removing feature worktree (--force) & branch in background (same commit as main, _)
$ ls ../repo.feature
ls: ../repo.feature: No such file or directory
```

</details>

## The fix

The gate is now git's comparison at git's granularity: the directory's
`.git` must name *this* registration, and that registration's `gitdir`
file must name the directory back. Repository-level ownership stays as
the weaker half of the conjunction — it is what rejects a `.git` file
pointing at another repository — and the main worktree is the same test
where there is no registration to point back at.

`ensure_belongs_to_repo` becomes `ensure_holds_this_worktree`, since it
no longer merely asks about repository membership.

Resolution moves to `Repository::git_dir_at`, the fs-only resolver the
`wt list` prewarm already used (`derive_worktree_git_dir`), generalized
to answer for a directory rather than for a known worktree of this repo:
its main-worktree branch returned `git_common_dir()` on trust, and now
canonicalizes the `.git` it actually found. It also never walks up to a
parent, which is what git reads too — `git rev-parse --git-dir` in an
emptied worktree can resolve the *enclosing* repository.

That settles a second thing the old docstring got wrong. It claimed the
plan→rename window was "narrower than `ensure_clean`'s"; in fact
`ensure_clean` re-runs `git status` while this gate answered from
`GIT_DIRS`, memoized process-wide, so the second call was vacuous and
the window — which contains the approval prompt and the `pre-remove`
hook — was unguarded. `git_dir_at` reads the filesystem on every call,
so the check at the rename now re-decides.

The refusal was `Directory @ … is not this repository's worktree`, which
is false in the sibling case: it *is* one of this repository's
worktrees, just not the one registered there. Its hint didn't fit either
— "move the directory aside, then run `git worktree prune`" is a
repo-wide prune, and with a sibling moved aside *both* registrations are
prunable, so following it clears both and leaves a live checkout that
has stopped being a worktree:

```console
$ mv ../repo.feature ../repo.aside && git worktree prune -v
Removing worktrees/repo.feature: gitdir file points to non-existent location
Removing worktrees/repo.other: gitdir file points to non-existent location
$ git -C ../repo.aside status
fatal: not a git repository: (null)
```

So the error carries where the occupant's own registration records it,
and each case gets the remedy that fits. Moving it back to that path
leaves prune with only the stale entry to clear:

```console
$ wt remove --force --yes feature
✗ Directory @ ../repo.feature does not hold the worktree registered there
↳ Removing it could destroy the worktree registered @ ../repo.other; move the directory back there, then run git worktree prune
```

That path is read through `canonicalize_with_parents`, because a
relative `gitdir` entry resolves against `<common>/worktrees/<id>` and
would otherwise reach the hint with the `..` chain still in it — and
plain canonicalization can't normalize a directory that no longer
exists, which is the case the arm is reached for. Normalizing there also
makes `crate::path::paths_match`, the crate's canonicalizing comparison
over that same helper, the right test for the gate, so there is no
second comparison beside it.

The gate's fail-closed behavior now rests on that helper resolving `..`
through the filesystem rather than collapsing it lexically — across a
symlink the two readings name different directories — so `src/path.rs`
records the constraint where a lexical rewrite would otherwise read as a
tidy-up.

The FAQ's "What can Worktrunk delete?" paragraph carried the same "a
*different* repository" framing and is corrected.

## Scope

Pre-existing, and 0.73.0 already narrowed it — 0.72.0 had no ownership
check at all and deleted foreign clones too. The guard has two call
sites (`prepare_worktree_removal` at planning, `stage_worktree_removal`
at the rename), so this reaches `wt merge --remove`, `wt step prune`,
and picker removal, not only `wt remove`.

One incidental tightening: `wt remove <bare-repo-path>` previously
passed the guard (a bare root's git dir *is* the common dir) and was
stopped only by the dirty check, which `--force` skips. It now refuses
at the guard.

<details>
<summary>One residual, left alone</summary>

`git worktree repair <path>` after the `mv` produces a *double
registration*: both `worktrees/repo.bar/gitdir` and
`worktrees/repo.feature/gitdir` come to record the same path, and `git
worktree list` reports two worktrees there. In that state the new gate
accepts the removal — the occupant does point at the `feature`
registration, and that registration does point back — while git refuses,
because its path→worktree lookup happens to match the `bar` entry first.
Closing it means knowing the registration id at the gate, or scanning
every `worktrees/*/gitdir` for duplicate claims. Unchanged by this PR,
and reachable only via `mv` followed by `repair`.

</details>

## Testing

Five new tests, each confirmed to fail with the line it covers reverted
and to leave the others passing. Three drive the binary:

- **the sibling case** — follows #3785's data-safety model: asserts the
filesystem afterwards, not just the exit code, since removal stages by
rename and deletes in a detached process. Snapshots the refusal, so the
hint and the path it names are pinned. Fails with the pointer-back
conjunct removed, while the foreign-repo test still passes without it —
the two cover different halves.
- **the re-check at the rename** — a `pre-remove` hook repoints the
worktree's `.git` at a sibling's registration after planning has already
cleared it, which is what makes the second gate's freshness observable.
Fails when resolution routes back through the `GIT_DIRS`-cached
`git_dir()`.
- **a relative `gitdir` entry** — removal succeeds, and git reads the
rewritten entry back, which is what makes it the form git itself writes.
Rewriting the entry rather than setting `worktree.useRelativePaths`
keeps the test independent of the git version that introduced the
option.

Two sit at the gate, where the CLI can't reach:

- **both worktree shapes are accepted** — including the main worktree,
whose git dir *is* the common dir. `wt remove` rejects the main worktree
well upstream of this gate and a bare repository's worktrees are all
linked, so nothing through the CLI would notice that arm inverting.
- **the refusal names a normalized path** — asserted against
`Diagnostic::render`, since the path is in the hint and `Display`
carries only the title.

Local gate green: 4607 tests, lints, doctests, rustdoc under
`-Dwarnings`.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:59:58 -07:00
Maximilian Roos cf822f75cf fix(worktree): guard a registered path that no longer holds its worktree (#3785)
A registered worktree's path was trusted to still hold that worktree.
Three defects followed, one of them destroying data, and the check that
would have caught them — where it existed at all — was `Path::exists()`.

## `wt remove --force` deleted an unrelated repository

A clone that came to sit at a stale registration's path was removed
whole, including uncommitted work and, for a repo never pushed, the only
copy of its objects. wt's own hint routed the user there: the dirty gate
reads `git status` in that directory, reports the occupant's changes as
this worktree's, and offers `--force` as the cure.

```console
$ wt remove feature
✗ Cannot remove worktree: feature has uncommitted changes
  ?? precious.txt                                          # ← the other repo's file
↳ ... to lose uncommitted changes, run wt remove --force feature
```

git refuses that same removal, `--force` included (`validation failed …
is not a .git file`). Worktrunk's fast path renames the directory into
trash rather than asking git to, so git's validation never ran.
`ensure_belongs_to_repo` makes it, comparing the directory's git dir
against this repository's: a linked worktree's sits under
`<common>/worktrees/`, the main worktree's *is* the common dir, anything
else answers to someone else. One comparison covers both worktree kinds
and also rejects a `.git` file pointing at another repo, which git's
shape test accepts.

It runs at planning, ahead of the dirty gate, and again at the rename
for callers that stage without planning. Now:

```console
$ wt remove --force feature
✗ Directory @ ../repo.feature is not this repository's worktree
↳ Removing it could destroy unrelated data; move the directory aside, then run git worktree prune
```

## A recreated worktree directory leaked git's exit 128

`wt switch`, `wt merge`, and `wt step push` walked into `git rev-parse
--git-dir failed (exit 128)`. Two of them probed `Path::exists()` first,
which a deleted-and-recreated directory passes; the third asked nothing.

`worktree_is_unusable` is the union of both tests, because neither
implies the other. `exists()` catches the absent directory; git's
`prunable` catches the recreated one. `prunable` alone is *not* the
wider test it looks like — git withholds the attribute from a **locked**
worktree even when its directory is gone, since prunability is its
pruning policy and a lock means "don't prune this":

```console
$ git worktree list --porcelain          # wt2 locked, all three directories removed
worktree /tmp/ptest/wt1
prunable gitdir file points to non-existent location
worktree /tmp/ptest/wt2
locked removable media                   # ← no prunable line
worktree /tmp/ptest/wt3
prunable gitdir file points to non-existent location
```

A locked worktree on an unmounted volume is exactly what
`prepare_worktree_removal`'s lock guard exists for, so a `prunable`-only
test would read it as healthy. All three commands now give the message
the merely-deleted case already gave.

`wt remove` keeps `exists()`, deliberately: there it is the precondition
for the cleanup path rather than a health test, since
`prune_worktree_entry` unregisters via `git worktree remove`, which
skips validation only while the directory is absent. No scoped git
command clears the recreated case, so it reports and names the repo-wide
`git worktree prune` that does.

## `wt switch docs/` missed a branch sitting right there

Git's ref format forbids a trailing `/`, so the branch lookup never had
a candidate — and shell completion produces exactly that spelling
whenever a `docs` directory sits beside the branch. Selectors are
normalized before resolution.

## Resolving selectors through one ladder

The three fixes landed in three of the four places that assemble "expand
shortcuts, try the branch, try the path, classify the failure" by hand.
Each gated its path attempt on "did something rewrite this token?",
answered by comparing an expansion's output against its input:

| where | the comparison |
|---|---|
| `resolve_worktree` | `branch == name` |
| `plan_switch` | `target.branch == branch` |
| `target_worktree_at_path` | `target.filter(\|t\| *t == resolved)` |
| `resolve_base_ref` | `resolved == base` |

That is a fact the rewriting step knows, re-derived downstream from its
output, and it is wrong in both directions. A shortcut can expand to the
token it was given — `-` pointing at the branch you are already on — and
string equality reads that as a literal, turning the path arm back on
for a token nobody typed. Normalization breaks it the other way, which
is why the trailing-separator fix needed threading through three call
sites.

`Selector` carries the fact instead: `expand_shortcut` reports whether
it fired, `wt switch` reports its `pr:`/`mr:` dispatch and remote-prefix
strip, and `names_a_path()` replaces all four comparisons.
`resolve_selector` is the ladder, and `plan_switch` expands into it
rather than re-implementing its phases.

`names_a_path()` gates both path steps together — the worktree-by-path
lookup and the directory verdict — which is what `wt switch --create`
needs: the argument names a branch to create, so `branch_only()` takes
the arm off at the producer rather than each consumer re-testing
`create`.

It also reaches the directory verdict, so `ResolvedWorktree` gains
`NoWorktreeAtPath` and the four sites that called `path_selector_error`
themselves stop re-deriving it. The docstring defending that laziness
didn't survive checking — the function returns on `is_valid_branch_name`
before touching the filesystem, so every ordinary branch name already
short-circuited.

|  | before | after |
|---|---|---|
| `normalize_selector` call sites | 3 | 1 |
| `path_selector_*` call sites | 4 | 2 |
| "was it rewritten?" comparisons | 4 | 0 |

## Navigating the diff

- `src/git/repository/mod.rs` — `Selector`, `normalize_selector`, the
new `ResolvedWorktree` variant.
- `src/git/repository/worktrees.rs` — `expand_shortcut`,
`expand_selector`, `resolve_selector`, `usable_worktree_for_branch`.
- `src/git/repository/working_tree.rs` — `ensure_belongs_to_repo`, the
ownership check.
- `src/git/remove.rs`, `src/commands/repository_ext.rs` — where it gates
removal, and why before the dirty gate.
- Call sites: `commands/worktree/switch.rs`,
`commands/worktree/push.rs`, `commands/merge.rs`, `commands/remove.rs`,
`git/repository/config.rs`.

## Size

Comments and docstrings are the largest share: the ownership check and
the four conditions behind the directory verdict all look like things to
simplify away, so the reason each exists is recorded where it's
enforced.

| | + | − |
|---|---:|---:|
| Production code | 277 | 142 |
| Comments & docstrings | 320 | 75 |
| Tests | 325 | 8 |
| Snapshots | 186 | 0 |
| Docs | 6 | 0 |
| **Total** | **1114** | **225** |

## Testing

Seven new tests. The data-safety one drives the real binary and asserts
the filesystem afterwards, not just the exit code — removal stages by
rename and deletes in a detached process, so a passing exit would not
have caught a staged-then-deleted tree. The others cover the recreated
directory (switch and remove), the trailing separator, `--create`
against a worktree registered at that path, and, at the unit boundary,
the four states of `worktree_is_unusable` — healthy, absent,
locked-and-absent, recreated — and the selector's path-ness, including
the degenerate case string equality got wrong. Each new test was
confirmed to fail with its fix reverted.

One more covers an omitted merge target in a repo whose default branch
can't be determined. `^` had a test for that error; the omitted-target
route to the same message had none. The gap predates this branch — the
closure is byte-identical to the one it replaces and codecov records
those lines as missed at the base commit too — but relocating them into
`resolve_target_selector` re-counted them as patch lines, which is what
surfaced it.

Local gate green: 4593 tests, lints, doctests, rustdoc under
`-Dwarnings`.

<details>
<summary>Behavioral matrix, verified against a build</summary>

```console
docs/ (trailing sep)      ▲ Worktree for docs @ ../repo.docs
detached by path          ▲ Worktree for detached worktree @ ../repo.det
leftover dir              ✗ No worktree @ ../repo.leftover
recreated dir             ✗ Worktree directory missing for rec
shortcut ^                ▲ Worktree for main @ ../repo
remove leftover           ✗ No worktree @ ../repo.leftover
--base docs/              ✓ Created branch nf from docs
foreign-repo remove       ✗ Directory @ ../repo.frn is not this repository's worktree
                             precious.txt survives
```

</details>

<details>
<summary>Also swept, and one thing left alone</summary>

Three more instances of the same shape, fixed here:

- `resolve_base_ref` was the fourth copy of the comparison, so `--base
docs/` now resolves too.
- `hint_for_repo` suggested `wt switch ^` after an existence probe a
recreated directory passes, pointing at a worktree the switch then
refuses.
- The pre-switch hook's `target` var used the bare shortcut expander, so
a hook saw `docs/` where the switch resolved `docs`.

The identical unborn/stale default-branch block in
`require_target_branch` and `require_target_ref` is extracted. The rest
of that pair differs in its existence predicate, extra arms, and final
error; sharing it would cost more in parameters than the duplication
does.

Left alone: `live_sibling_checkout` decides whether another worktree
still holds a branch during removal, and also uses `exists()`. Switching
it to `prunable` would make branch deletion *more* likely in a corner
case where the detached path already answers the other way. That is a
data-safety surface and a separate decision.

</details>

> _This was written by Claude Code on behalf of max-sixty_
2026-08-09 06:23:21 -07:00
Maximilian Roos 497551e076 fix(nix): give the devShell every tool the test suite shells out to (#3768)
`devShells.default` listed `bash`, `zsh`, `fish` under a `# For shell
integration tests` comment, but that feature drives two more shells and
shells out to `jq`. So `nix develop` could not run `--features
shell-integration-tests`, which is what the repo's own gate runs
(`.config/wt.toml` → `cargo insta test … --all-features`). This adds
`nushell`, `powershell` and `jq`.

The same dependency claim was stated, wrongly, in three other places.
All four now name the real set:

| Where | Was | Now |
|---|---|---|
| `flake.nix` devShell | bash, zsh, fish | + nushell, powershell, jq |
| `Cargo.toml` feature comment | bash, zsh, fish | + nu, pwsh, jq |
| `tests/CLAUDE.md` | bash/zsh/fish + PTY | + nushell, pwsh, jq |
| `docs/content/faq.md` | bash, zsh, fish, nushell | + pwsh, jq |

`flake.nix` also referenced a `CLAUDE.md → "Shell/PTY Integration
Tests"` heading that exists nowhere in the repo; it now points at the
section that does.

## Verification

There is no `nix` on the machine this was written on, so the two claims
were checked separately rather than by entering the shell.

**Is the list right?** A symlink farm modelling a devShell's `PATH` —
nixpkgs stdenv's own tools plus the candidate list, and nothing else —
with the full `--all-features` suite run under it.
`configure_pty_command` propagates the test process's `PATH` into PTY
children, so this reaches the shell tests.

<details>
<summary>Runs (the control is what makes the failures
attributable)</summary>

| PATH | Result |
|---|---|
| Full ambient PATH (control) | 4572 passed, 0 failed |
| stdenv + every tool the suite needs | 4572 passed, 0 failed |
| …minus `pwsh` | 3 `shell_powershell` tests fail |
| …minus `jq` |
`test_worktree_remove_hook_skips_path_holding_no_worktree` fails |
| …minus `python3` / `lsof` / `ps` | 6 failed: 2 `for_each`/`post_start`
(python3), 1 `remove::test_remove_reap_kills_process` (lsof), 3
pgid/process-probe (ps) |

The control run matters: it establishes that every failure above is
caused by the withheld tool rather than by a local flake.

The last row is about what the *suite* needs, not what the shell was
missing — see the correction below.

</details>

**Does the flake still evaluate?** `nixos/nix` in a container,
evaluating `devShells.<system>.default` for all four systems
`flake-utils` covers, against unmodified `main` as a control. All four
evaluate, and every tool resolves on each.

Not verified: nothing here was *built*, only evaluated, so a package
that evaluates but fails to build would not have been caught. The
nightly `nix-flake` job runs `nix flake check` on PRs touching
`flake.nix`, which covers that on x86_64-linux.

<details>
<summary>A correction: python3/procps/lsof were never missing</summary>

The first version of this PR also added `python3`, `procps` and `lsof`,
claiming the shell could not run a plain `cargo test`. That was wrong,
and worktrunk-bot caught it.

`craneLib.devShell` sets `inputsFrom = builtins.attrValues checks ++
inputsFrom`, and `mkShell` folds each `inputsFrom` derivation's
`nativeBuildInputs` into its own. `checks` includes `worktrunk-tests`,
whose `nativeBuildInputs` already carry `git`, `python3`, `procps` and
`lsof` — so the devShell inherited all four. Evaluating the unmodified
`main` tree confirms it: its `x86_64-linux` devShell derivation already
contains `python3`, `procps` and `lsof`, and contains no `nushell`,
`powershell` or `jq`.

The symlink farm could not have caught this: it modelled stdenv plus the
literal `packages` list, so crane's inherited inputs were invisible to
it by construction. The experiment established what the *suite* needs;
it said nothing about what the *shell already had*.

Those three lines are dropped. `git` remains listed in both places —
pre-existing, and left alone here.

</details>

<details>
<summary>A guard I added and then removed</summary>

`powershell` first went in behind `lib.meta.availableOn`, because
nixos-unstable's PowerShell has no `x86_64-darwin` source. Testing that
guard against nixpkgs HEAD showed the actual cause: nixpkgs 26.11
dropped Intel macOS wholesale, so the entire flake fails to evaluate
there regardless of the guard. On every system nixpkgs still supports,
PowerShell has a build — the guard protected against nothing, and its
comment justified it with the wrong mechanism. Removed; `powershell` is
listed plainly with the other shells.

</details>

## Notes

`task setup-web` checks for `bash zsh fish nu` and not `pwsh`/`jq` — the
same gap in a different environment, left alone here since its install
mechanics are unrelated.

> _This was written by Claude Code on behalf of max-sixty_
2026-08-07 16:56:56 -07:00
Maximilian Roos 4a6fd1ec44 refactor(merge): leave target-worktree changes in place, drop the autostash (#3703)
`wt merge` / `wt step push` previously moved a dirty target worktree's
uncommitted changes aside with an autostash (`git stash push -u`) and
restored them after the push. That design entered `refs/stash` — a
repo-global namespace any process can mutate, the source of #3683's race
class — restored staged changes as unstaged, and existed only because
the fast-forward's `receive.denyCurrentBranch=updateInstead` refuses any
dirty worktree at all.

Both strategies now advance the target through one `advance_target`:

- a compare-and-swap `update-ref` (fails cleanly if the target moved
since the snapshot; reflog entries are labeled),
- `update-index -q --refresh` + `read-tree -m -u <old> <new>` in the
target worktree — git's documented lenient `push-to-checkout` policy
(githooks(5)),
- a CAS rollback when the sync can't apply, so branch and worktree move
together or not at all.

Uncommitted changes at paths the push doesn't touch never move: unstaged
edits stay unstaged, staged entries stay staged, untracked files stay
put, and `refs/stash` is never involved. The autostash machinery
(`TargetWorktreeStash`, `StashData`, `stash_restore_failed` and its
exit-code path from #3693) is deleted — including the
staged-restored-as-unstaged flaw, which disappears with the restore
itself.

Behavior changes:

- Receive hooks no longer fire on the fast-forward path — there is no
`git push`. A `git merge` run in the target wouldn't run them either,
which is the line the module spec draws.
- A sync that can't apply fails the whole command with the ref rolled
back; `--no-ff` previously warned and left the worktree stale behind its
own branch.
- An untracked-path collision is refused upfront, naming the file,
instead of stashed and later maybe-conflicting.

Review highlights (three adversarial passes over the diff):

- The upfront conflict check reads `status --porcelain -uall`, so
untracked files inside untracked directories get the named upfront
refusal rather than a generic sync failure (and one subprocess is
dropped).
- A commit racing into the CAS→sync window is detected by a post-sync
ref re-read — receive-pack used to give the fast-forward path this check
structurally — and warned about.
- `read-tree` runs under `-c submodule.recurse=false`, keeping #1604
fixed for `submodule.recurse=true` users.
- The ignored-file carve-out (an ignored file at a path the push tracks
is overwritten, as a `git merge` there would) is pinned by an end-to-end
test: two review passes claimed `read-tree` refuses it; experiment on
git 2.55 refuted both.

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 23:10:55 -07:00
Worktrunk Bot 4af04c2260 fix(remove, prune, merge): target the named worktree, retain shared branches (#3533)
Follow-up to the discussion on #3480: [@max-sixty
asked](https://github.com/max-sixty/worktrunk/pull/3480#issuecomment-5039137116)
to have `wt remove` honor an explicit path generally. #3607 has since
landed the resolution half, `Repository::resolve_worktree`, which turns
a path into a worktree. This is the removal half, and it is the part
resolution cannot decide: which worktree to act on is a naming question,
whether the branch may be deleted is not.

## Problem

`wt remove` threw the resolved answer away. For any non-current worktree
it re-targeted by branch name, and `prepare_worktree_removal` mapped
that branch back to git's *first-listed* worktree. Two failures
followed, both silent.

**The wrong worktree is removed.** With `feature` checked out twice:

```console
$ git worktree list
…/dup    [feature]
…/first  [feature]

$ wt remove …/first
◎ Removing feature worktree & branch in background (same commit as main, _)
```

`…/dup` is gone. `…/first`, the one named, is still there.

**The survivor is left broken.** The shared branch is deleted with it.
Worktrunk deletes branches with `git update-ref -d`, git's
compare-and-swap primitive, which unlike `git branch -d` does not refuse
a ref that is checked out somewhere:

```console
$ git worktree list
…/first  0000000 [feature]

$ git -C …/first rev-parse HEAD
fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.
```

`wt step prune` reached the same deletion unattended, and `wt merge`
reached it with a freshly integrated branch, so nothing else declined. A
branch gets a second worktree only through `git worktree add --force`;
worktrunk never does it itself.

## Fix

**Remove the worktree that was named.** `wt remove` drops non-current
worktrees via `RemoveTarget::Path`. `wt step prune` does the same: its
candidates already carry a path, and targeting a *stale* entry by branch
name resolved to a live worktree that the same prune had just skipped as
too young, then removed it.

**Retain a branch another worktree holds.** One predicate,
`live_sibling_checkout`, answers "would deleting this ref orphan a
checkout?", and every path that can delete a branch asks it:
`prepare_worktree_removal`'s worktree and pruned-branch-only arms
(covering `wt remove`, `wt step prune`, and the picker, which already
targeted by path) and `wt merge`'s finish. A hit forces
`BranchDeletionMode::Keep`, the single chokepoint every deletion path
honors, and names the surviving checkout:

```console
$ wt remove …/dup
◎ Removing feature worktree in background
○ Branch feature retained; still checked out @ …/first
```

A sibling whose *directory* is already gone is stale metadata, not a
checkout with anything to lose, so it does not retain: removing the last
live checkout still deletes the branch.

**`-D` is refused out loud.** Everywhere else `-D` is the override that
wins, so one that cannot be honored warns rather than passing quietly:

```console
$ wt remove …/dup -D
◎ Removing feature worktree in background
▲ Branch feature retained despite -D; still checked out @ …/first
```

The ordinary single-checkout case is unchanged, and a retained branch
skips the integration check entirely rather than computing a verdict it
would discard.

#3480's duplicate-checkout hint now points at `wt remove <path>`, which
this makes the safe answer.

## Testing

Full gate green. New coverage, each case asserting the survivor still
resolves `HEAD`, which is the corruption in question:

- `remove`: by path, by name, refused `-D`, the pruned-directory
fallback, and the mirror case where a stale sibling must *not* retain.
- `step prune`: a stale entry whose branch is live in an age-skipped
worktree. This test is what surfaced the wrong-worktree bug in prune.
- `merge`: merging a branch that a `--force` duplicate also holds.

## Not addressed

`wt step prune`'s summary counts candidates rather than outcomes, so a
retained branch still reports `✓ Pruned 1 branch`. The per-item line
above it already says the branch was retained. Fixing the count means
threading removal outcomes back through prune's accounting, which is a
separate change.

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

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 09:57:34 -07:00
Maximilian Roos 9645e3e132 fix(shell): reclaim the legacy wrapper paths instead of inspecting them (#3602)
`wt config shell install` decided whether the file at a legacy location
was worktrunk's by reading it — a substring test for the wrapper header,
falling back to "every code line looks like an integration line".
Anything that didn't match was left in place. Fish sources `conf.d` at
startup, so whatever `conf.d/{cmd}.fish` defines is already loaded by
the time fish would autoload the `functions/{cmd}.fish` the install just
wrote — the stale definition wins and the new wrapper may never load.

Ownership is now the path. `conf.d/{cmd}.fish` and the stranded nushell
`{cmd}.nu` candidates are paths worktrunk itself computes for the
command name being installed, so install takes them back whole without
reading them. Only that exact filename is touched: a neighbour under
another name is not worktrunk's, and each removal is still reported.

`wt config shell uninstall` still reads the header, because it takes no
`--cmd` and so genuinely doesn't know the name — it lists the
shell-owned directories and has to tell our `{cmd}.fish` from the user's
own files beside it. That is the one place ownership can't come from the
path, and it prompts and previews every file before removing it, which
install does neither of. `is_worktrunk_managed_content`'s docstring now
says so; `is_worktrunk_managed_nushell` existed only to gate the
install-time deletion and is gone.

Audited the rest of the surface for the same principle: the OpenCode
plugin (`~/.config/opencode/plugins/worktrunk.ts`) is already removed by
path with no inspection, the Claude plugin delegates to `claude plugin
uninstall`, Gemini installs through `gemini extensions install` and
leaves nothing of ours on disk, and fish is the only shell with a
separate completion file — which uninstall already covers. No other
gaps.

## Data safety

This deliberately widens what install deletes, so it's worth naming: two
tests asserted the old conservatism and now assert the new boundary —
`test_configure_shell_fish_reclaims_conf_d_path` (was
`..._preserves_user_conf_d_file`, from #3589) and
`test_nushell_install_reclaims_only_the_command_name` (was
`..._keeps_unmanaged_legacy_file`, from #2992). Both put a neighbour
file in the same directory to pin that only `{cmd}.{ext}` is taken. The
FAQ's "What can Worktrunk delete?" inventory is updated to describe
path-based ownership rather than the old content-marker rule.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:10:00 -07:00
Maximilian Roos b843051906 docs(faq): scope the worktree-lock advice to removal (#3601)
The FAQ recommended `git worktree lock` "for worktrees containing
precious ignored data (databases, caches, large assets)". The lock does
not do that — it blocks removal and nothing else. Someone who locked a
worktree to keep a local database safe has protection against `wt remove
--force`, and none at all against the thing that actually rewrites files
there.

An ignored file in a locked worktree is still replaced when commits
arriving via `wt merge` or `wt step push` track a file at the same path.
Reproduced against this FAQ's own example: a locked worktree holding
`db.sqlite` had it overwritten by the branch's tracked file, exit 0, no
warning.

That behavior is git's, not worktrunk's. A plain `git merge` in that
worktree does the same, and draws the line in the same place — it
refuses when the colliding file is untracked, and overwrites when it's
ignored:

```
ignored locally   → Fast-forward, db.sqlite | 1 +      (content: TRACKED-FROM-BRANCH)
untracked locally → error: The following untracked working tree files
                    would be overwritten by merge  (content: REAL-LOCAL-DATABASE)
```

So the fix is one sentence: the opener now says what the lock buys
(removal protection) instead of implying it guards ignored data. Nothing
in the paragraph below it changes — `wt remove --force` and `git
worktree remove --force` do both still refuse on a locked worktree,
checked against a scratch repo while editing.

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-25 13:15:47 -07:00
Maximilian Roos b623d7f951 fix(shell): don't delete user data that only mentions the init command
Three places decided whether text was worktrunk's by testing a blob for
substrings. Two of them deleted the user's data when they guessed wrong.

`wt config shell uninstall` asked two unlinked questions of an rc line —
is an exec keyword anywhere in it, and is `<name> config shell init`
anywhere in it — so `alias setup='echo "run: wt config shell init fish |
source"'` classified as worktrunk's and was removed. One question
replaces both: does the line invoke the init command, with the name in
command position? The exec-keyword scan is gone rather than kept
alongside; it read English words out of the whole line and rejected
nothing the positional rule doesn't.

`wt config shell install` had the same defect one file over and one step
worse: `is_worktrunk_managed_content` tested the whole file for
`config shell init` and `| source`, and `cleanup_legacy_fish_conf_d`
deleted a user's own `conf.d/wt.fish` outright — during an install, which
never offered to remove anything. The cmd-agnostic twin beside it already
answered correctly, so the substring version is gone.

Uninstall now prints every line it takes, in the confirmation and again
after removal, since shell quoting stays undecidable without running the
line.

Sweeping for the same shape found it in `az` failure classification,
where `stderr.contains("login")` alone meant "not authenticated" and
`bail!` discarded the real error; `az` states the remedy itself, so its
text now reaches the user. Provider host matching moved from
`contains("github")` to label-boundary comparison, so
`dev.azure.com.attacker.example` and `mygithubmirror.com` no longer
resolve to Azure DevOps and GitHub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 19:25:31 -07:00
Maximilian Roos f9cd38eb88 fix(shell): validate command names and tighten install/uninstall detection (#2864)
Two shell-integration correctness fixes plus an uninstall-scope
redesign.

**Command-name validation.** The shell-integration command name comes
from the user-typed `--cmd` flag, or `argv[0]` when `--cmd` is omitted.
A malformed value (an empty string, a leading `-`, or characters like
`;` or whitespace) renders into a broken shell rc line that the user
then has to find and fix. `validate_shell_command_name()` rejects empty,
leading-`-`, and non-`[A-Za-z0-9._-]` names at the CLI entry points, so
a bad value produces a clear error up front instead of a silently broken
config file. This is input hygiene on a value the user already controls,
not a security boundary.

**Noncanonical-line detection on install.** Install recognized only its
exact current canonical line, so a manually-added or older-form
integration line (e.g. `eval "$(wt config shell init zsh)"`) was not
seen as already-present and a duplicate was appended on the next
`install`. It now detects those forms and reports already-configured.

**Uninstall scans every worktrunk-managed file, regardless of cmd.**
Previously `uninstall` located Fish/Nushell wrapper files by name
(`{cmd}.fish` / `{cmd}.nu`), so removing integration installed under an
alternate binary name needed a matching `--cmd`. That symmetry leaked an
implementation detail: uninstall already has a content marker to
recognize worktrunk wrappers regardless of binary name. It now scans the
wrapper directories (`~/.config/fish/functions`, `conf.d`; the nushell
candidates' `vendor/autoload`) and admits every file whose content
matches the cmd-agnostic marker `# worktrunk shell integration for
<shell>` (always emitted by the wrapper templates). A headerless file
(the legacy `conf.d/{cmd}.fish`, which held nothing but the init line)
qualifies only when every non-blank, non-comment line is an integration
line, so a user's own file that runs or merely mentions `wt config shell
init` amid other code survives the scan. Fish completion files carry
their own `# worktrunk completions for` header and are scanned the same
way, so a completion whose wrapper is already gone is still cleaned up.

For Bash/Zsh/PowerShell (line-based),
`is_shell_integration_line_for_uninstall_any_cmd` reads the binary name
off the line rather than pattern-matching it. `config shell init` is a
fixed literal, so the name is the run of command-name characters ending
at it, held to the same `validate_shell_command_name` rule that governs
what worktrunk writes into shell code. The execution-context check that
pairs with it is a line-level property, so it lifts out of the
per-position loop it used to sit in, which is what let both cmd-agnostic
detectors drop their regexes. Since the result asks only whether a line
is worktrunk's, not which install owns it, uninstall also removes
hand-written `git wt config shell init` lines, and it cleans every
matching profile file rather than the first (both PowerShell profiles on
Windows). All detection reads only the code portion of a line: a
trailing `#` comment can mention an integration line without running it,
so uninstall does not delete an unrelated `eval`/`source` line over a
comment, and install does not treat such a mention as
already-configured. The cmd-specific permissive detector lost its last
caller in this cutover and is deleted.

The `--cmd` flag is removed from `wt config shell uninstall`; `install`
keeps it (installing under an alternate name is still a deliberate
per-cmd act). The FAQ's "What can Worktrunk delete?" inventory documents
the widened uninstall surface.

Covered by new integration tests: custom-cmd install + cmd-less
uninstall round-trips for zsh/fish/nushell (verifying scan-all removes
the custom-cmd integration, a hand-written `git wt` line, a stale second
wrapper, and an orphaned completion), a PowerShell uninstall round-trip
on the old pre-`Out-String` line, malformed-name rejection at the CLI
edge (`--cmd` and `argv[0]`), and the older-line dedup case. Unit tests
pin the deletion criterion: worktrunk's own wrappers and the headerless
legacy init file match; a file that mentions the integration, or runs it
amid other code, does not.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Worktrunk Bot <w@worktrunk.dev>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-24 09:32:17 -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
Maximilian Roos e74b4241bf Consolidate -vv notifications onto diagnostic.md, led by the performance profile (#3329)
`-vv` runs write log files to `.git/wt/logs/`. Previously the stderr
output was noisy — a 5-path startup gutter, then the performance profile
and the diagnostic bundle each announced separately at exit, with
`profile.txt` / `diagnostic.md` named twice. This consolidates it.

## What changed

**One doc to read.** `diagnostic.md` is now the single human-facing
report, and it leads with the performance profile (`<details open>`,
promoted above the environment / worktree / config dumps) instead of
burying it fifth. The standalone `profile.txt` is removed — nothing read
it (`wt config state logs profile` re-renders live from `trace.jsonl`),
and its content already lives in the bundle.

**Two stderr lines, both `○`.** A `-vv` run now opens with a one-line
pointer to the log directory and closes by naming what it captured:

```
○ Verbose logging to ~/…/.git/wt/logs/
…command runs…
○ Logs, performance profile, and diagnostics saved @ ~/…/.git/wt/logs/diagnostic.md
  ~/…/.git/wt/logs/trace.jsonl
  ~/…/.git/wt/logs/subprocess.log
↳ To report a bug, create a secret gist with gh gist create --web ~/…/diagnostic.md and reference it from an issue at https://…
```

The two files listed beneath are the raw companions the bundle doesn't
inline — `trace.jsonl` (machine source) and `subprocess.log` (uncapped
bodies). `trace.log` (inlined, truncated) and the profile (inlined
verbatim) are omitted to avoid double-listing.

**More rows.** The profile now reports the 20 slowest calls (was 8) and
10 same-context redundant-command offenders (was 3).

## Testing

Integration tests in `tests/integration_tests/diagnostic.rs` cover both
stderr blocks, the file set written at `-vv`, and that `diagnostic.md`
leads with the profile. Verified against real `wt -vv list` output; full
pre-merge gate green (4301 tests).

> _This was written by Claude Code on behalf of max_

---------

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

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

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

## The three changes

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

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

## Benchmark

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

## Reviewer orientation

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

> _This was written by Claude Code on behalf of max_

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

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

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

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

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

## What a reviewer needs

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

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

## Testing

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

> _This was written by Claude Code on behalf of max_

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

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

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

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

### Where to look

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

### Testing

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

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
Maximilian Roos 7e188821fc Polish profile summary line and surface profiler in bug-report docs (#3186)
Small follow-ups to the `wt config state logs profile` profiler (landed
in #3184), polishing its output and making it discoverable when triaging
perf reports.

**Summary line consistency.** The profile summary mixed duration shapes
— `32.00ms subprocess time` led with the value, but `traced 18.00ms` led
with the label. `traced` was the only outlier, so this flips it to
`18.00ms traced`. Now every measured magnitude (counts and durations)
leads with its value; the derived metrics (`parallelism`, `peak`) keep
their label since they aren't quantities with units.

**Profiler in the bug-report path.** Every `-vv` run already writes
`diagnostic.md`, and it now embeds a rendered performance profile — but
nothing pointed reporters or triage at it. So: the FAQ's `diagnostic.md`
entry now mentions the profile, the "profile a slow tab-completion" tip
points at `wt config state logs profile` instead of "read `trace.log`",
and the `running-tend` skill tells triage to read the Performance
profile section first on slow-command reports.

Snapshot diffs are exactly the `traced` reorder. `fmt`/`clippy`/lib
tests/docs-sync all green locally.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:41:52 -07:00
Maximilian Roos 3d0b99e072 Add WORKTRUNK_VERBOSE env var equivalent to -v/-vv (#3166)
Shell tab-completion runs the `wt` binary as its own subprocess (the
shell sets `COMPLETE=<shell>`), and that path returns from `parse_cli`
before `main` ever reaches `logging::init`. So when a tab-completion is
slow, there's no flag that turns on logging for it — `-v`/`-vv` never
run, and `RUST_LOG` only sets a level, not the `-vv` file sinks. There
was no way to profile a slow completion.

This adds `WORKTRUNK_VERBOSE=0|1|2` as the env-var equivalent of the
`-v`/`-vv` flag count. It's read everywhere — including the completion
path, which no flag can reach — and combined with the flag via `max`, so
the env sets a baseline the flag can raise but never lower. Completion
behaves *identically* to a flagged command at the same level: at level 2
it writes the same `trace.log`/`subprocess.log`/`diagnostic.md` under
`.git/wt/logs/`, so a slow tab-completion can be profiled with:

```console
$ WORKTRUNK_VERBOSE=2 COMPLETE=fish wt -- wt switch ''
```

then reading `trace.log`. (Set it inline like that, or as a one-off,
rather than `export`-ing it — an exported value makes *every* TAB run as
`-vv`, printing the "Writing to…" banner above your prompt and
re-truncating the shared trace files on each keystroke. That's just
normal `-vv` shared-sink behavior, but it's noisy interactively.)

The one place completion deliberately diverges: it strips
`WORKTRUNK_VERBOSE` from the environment of any forwarded `wt-*`
custom-subcommand child, so the child doesn't re-run `logging::init` and
clobber the trace files the parent completion just wrote (its stderr is
discarded anyway).

### Testing

Integration tests cover: `WORKTRUNK_VERBOSE=2` opens the trace files
like `-vv` while `=1` does not; flag `-vv` combined with env `0` still
writes (the `max`); and completion at level 2 writes `[wt-trace]`/`$
git` records to `trace.log` while candidates still go to stdout. A unit
test pins the lossy parse (empty/garbage/out-of-range → `0`, never an
error) so a stray value can't corrupt the completion candidate list.
Docs (faq, config, the env-var table) and help snapshots are synced.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:46:06 -07:00
Maximilian Roos c511d3fa2b feat(list): show PR/MR number in the CI column (#3041)
The CI column in `wt list --full` (and the statusline) previously showed
a single colored dot. It now shows the branch's open PR/MR reference —
`#3035` on GitHub/Gitea/Azure DevOps, `!3035` on GitLab — colored by CI
status, dimmed when stale, and hyperlinked to the PR. When no number is
available (branch workflows without a PR/MR, pre-number cache entries,
or a number wider than the allocated column), the cell shows a bare `#`
in the same colors. Fetch errors always render `⚠`, even when a number
is known — Error and Conflicts share yellow, so a yellow `#3035` would
read as a conflicted PR.

The branch merges main's review-state feature (#3044): review colors
(magenta/cyan) and draft dimming apply to the number cells exactly as
they did to the dot, and the `--help` legend shows colored `#` samples
for all seven states (the interim version had dropped the colored
samples from the legend entirely).

## The width problem

`wt list` renders skeleton-first: column widths are fixed before any CI
data arrives, and the table never resizes mid-render. The PR number's
width therefore has to be known up front. The solution is a repo-level
ratchet cache (`.git/wt/cache/pr-number/max.json`) holding the largest
PR number any fetch has seen — PR numbers are monotonic per repo, so the
value needs no invalidation. Pre-skeleton, `collect` reads that one file
and sizes the column exactly; on a cold cache the estimate is 5 chars
(`#9999`). A number that outgrows the estimate renders as the bare `#`
for that run and sizes correctly on the next run once the ratchet
records it. The ratchet is deliberately separate from the per-branch
`ci-status/` entries so the width hint isn't coupled to branch-entry
retention, and `detect` re-ratchets on cache hits too, so a deleted or
racily regressed `max.json` heals from locally cached numbers instead of
waiting out the TTL.

## Reviewer's map

- `src/commands/list/ci_status/mod.rs` — `PrRef` (number + forge sigil,
`PrRef::pr`/`PrRef::mr` constructors), `PrStatus.number` (serde-default
so pre-existing cache entries still deserialize, rendering `#` until
their 30–60s TTL expires), `format_cell` width-aware renderer with the
Error guard, ratchet in `detect` (both cache-hit and fetch paths)
- `src/commands/list/ci_status/cache.rs` — `MaxPrNumber` ratchet
(read/ratchet/clear)
- `src/commands/list/ci_status/{github,gitlab,gitea,azure}.rs` — each
fetcher populates the number (`gh --json number`, `iid`, Gitea `number`,
`pullRequestId`); GitLab's mr-view-failure path carries the
iid/URL/review state into the error status so the `⚠` stays clickable
- `src/commands/list/layout.rs`, `collect/mod.rs` — width estimate
threading
- `src/commands/list/render.rs`, `model/item.rs` — table cell and
statusline both go through `format_cell`
- `src/commands/list/json_output.rs` — `ci.number` field
- `src/commands/config/state.rs` — ratchet shown by `state get`/`cache
get` (table + JSON) and swept with the CI cache category, including the
deprecated `ci-status clear --all` path
- `src/md_help.rs`, `src/help.rs` — legend colorization rules rewritten
from `●` to `#` (terminal + website)

Most of the diff is snapshot churn from the column width and glyph
changes plus regenerated docs mirrors.

Known trade-offs: concurrent statusline ratchet writes can transiently
lose an update (monotonic, re-learns on the next render, documented at
the write site); one anomalously high PR number widens the column until
`wt config state cache clear`; an open Azure DevOps PR still shows gray
`NoCI` instead of its pipeline status — a pre-existing gap, now marked
`TODO(azure-pr-pipeline)`.

Testing: unit tests for `format_cell` (including the Error-with-number
and oversized-number link cases)/`pr_ref_width`/ratchet/width estimates;
integration coverage for all four forges with real numbers (the Gitea
mocks now exercise the number path too), review-state × number
composition, the GitLab mr-view-failure `⚠` and branch-pipeline success
paths, cache-TTL expiry → refetch, the statusline number view, and the
`wt config state` surfaces.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:36:44 -07:00
Maximilian Roos 5da0d2c3e4 docs: alias template-expansion timing; fix narrow help-test env redaction (#3006)
## Docs: alias template-expansion timing

The main addition documents a gotcha that's easy to hit and hard to
diagnose: an alias body renders its `{{ … }}` once at dispatch, in the
invoking worktree, so a per-worktree variable like `{{ branch }}` is
baked to a single value *before* a nested `wt step for-each` / `wt
switch --execute` iterates — printing the same value in every worktree.
The fix is `{% raw %}…{% endraw %}` deferral (plus a quoted `sh -c '…'`
for `for-each`, since the deferred `{{ branch }}` contains spaces).

- `extending.md` — rewrote "Deferring expansion to a nested `wt`
command" around the `for-each` symptom; improved the `up` rebase recipe.
- `faq.md`, `troubleshooting.md` — symptom-first entries with `wt config
alias dry-run` as the diagnostic.
- `hook.md` / `config.md` / `step.md` — distinguish repo-level
(constant) vs per-worktree (active) variables; note `{{ default_branch
}}` needs no deferral; cross-link the `{{ default_branch }}` variable vs
the `wt config state default-branch` shell command.

## Factual corrections

- `integration_reason` JSON values are hyphenated (`trees-match`,
`no-added-changes`, `merge-adds-nothing`) — the docs had underscores.
Verified against `src/commands/list/model/state.rs`.
- `SKILL.md`: 7 → 10 hook types (5 events × pre/post), added an
aliases/multi-worktree task section, fixed stale anchor links.

## Test fix: narrow help-test env redaction

`test_help_list_narrow_terminal` built its own `insta::Settings` but
skipped `add_standard_env_redactions` (every other help snapshot routes
through `snapshot_help`, which calls it). Its snapshot env block
therefore leaked host-specific paths (`LLVM_PROFILE_FILE` = the
machine's temp dir, plus the `WORKTRUNK_*` paths), which churn whenever
the snapshot is regenerated on a different machine. Adding the one call
mirrors `snapshot_help` and makes the snapshot reproducible.

Worth noting (and a candidate follow-up): this gap was masked under
`cargo test` (libtest) because the `repo` fixture's `mem::forget`'d
`bind_to_scope()` guard leaks redaction settings across the shared
process's reused threads. Under nextest (process-per-test, what the
pre-merge hook uses) there's no leak, so a test missing its own
redactions is exposed. A few other help tests (`test_help_md`,
`test_version`, `test_nested_subcommand_suggestion`) have the same gap
and could be consolidated through one settings helper — left out of this
PR to keep it focused.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:00:52 -07:00
Maximilian Roos 293b44245c docs(faq): add entry on moving uncommitted changes to a new worktree (#3002) 2026-06-06 20:04:52 -07:00
Worktrunk Bot b75b3cf1a7 fix(shell): install nushell wrapper to vendor-autoload dir (#2878) (#2992)
Fixes #2878.

Implements the approach agreed in the issue: query
`$nu.vendor-autoload-dirs` directly and install to its last
(user-writable) entry, with cross-platform tests and stranded-file
cleanup on install/uninstall.

## The bug

The nushell integration wrote `wt.nu` to
`$nu.default-config-dir/vendor/autoload`, which Nushell **never
autoloads**. It happened to work on macOS/Windows because there
`$nu.default-config-dir == $nu.data-dir`, so the wrong subpath resolved
to the real vendor dir; on Linux the wrapper was written but silently
never loaded — so `wt` was never wrapped.

Verified against `nu` 0.113.1: `$nu.vendor-autoload-dirs | last` is
`<data-dir>/vendor/autoload`, and
`$nu.default-config-dir/vendor/autoload` is in neither
`$nu.vendor-autoload-dirs` nor `$nu.user-autoload-dirs`.

## The fix

- **Resolve the install target from `$nu.vendor-autoload-dirs | last`**
(one memoised `nu` spawn that also reads `$nu.default-config-dir` for
legacy cleanup).
- **Fallback when `nu` isn't on PATH** mirrors nushell's own
`nu_path::data_dir`: `XDG_DATA_HOME` (when absolute) wins on every
platform, otherwise `dirs::data_dir()` — matching nushell on
Linux/macOS/Windows.
- **Wrapper-based shells now always write to the canonical location**
(`paths.first()`), so a wrapper stranded at a legacy path can't become
the write target. (No-op for fish, which has a single canonical path.)
- **Stranded-file cleanup**: install writes the correct path and removes
any worktrunk-managed wrapper left under the legacy
`<config-dir>/vendor/autoload` locations; uninstall already iterates
every candidate, which now includes those legacy paths. Legacy files are
only removed when they carry the `# worktrunk shell integration for
nushell` header, so a user's own `wt.nu` is never touched.
- `config_line`, the `wt config shell init` manual-setup help, and the
FAQ/shell-integration docs now use `$nu.vendor-autoload-dirs | last |
path join wt.nu`.

## Tests

- New `WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR` override pins the install
dir so the install/uninstall/cleanup tests are **deterministic on Linux,
macOS, and Windows** and don't depend on `nu` being installed.
- New `test_nushell_install_target_is_a_vendor_autoload_dir` (gated on
`shell-integration-tests`, where CI has `nu`) runs the **real `nu`
flow** and asserts the wrapper worktrunk wrote is a member of
`$nu.vendor-autoload-dirs`. This fails against `main` (the old
config-dir path is in neither autoload list) and passes with the fix.
- New `test_nushell_install_cleans_stranded_legacy` and a reworked
`test_uninstall_nushell_cleans_all_candidate_locations` cover the
migration cleanup.
- Existing nushell tests updated to the data-dir path; full suite green
(`cargo test --lib` + `cargo test --test integration`, and the nushell
subset with real `nu` under `--features shell-integration-tests`).

## Not in scope

"Not best practice" cleanup flagged in the issue (the overlap between
the candidate-building helpers, and the dubious Windows branch) — the
helpers here are restructured around the autoload model, but a broader
tidy can be a follow-up.

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

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:43:38 -07:00
Maximilian Roos 2617348b22 docs: writing-prose cleanup (faq, config, list, remove) (#2925)
Continues the writing-prose pass on the remaining doc-site pages — the
items deferred at the end of #2922.

**`faq.md`** — dropped "Worktrunk creates files in four categories."
scaffolding; the four H3s below (Worktree directories, Config files,
Shell integration, Metadata in .git) make the count self-evident.

**`config.md`** (edits in the `Config` command's `after_long_help` in
`src/cli/mod.rs`) — four small fixes:
- Replaced the "For context:" three-bullet preamble in *User
project-specific settings* with a direct lead sentence. Side benefit:
fixes the "User configs _also_ has" grammar bug. The new sentence avoids
second-person ("for you") and third-person addressing ("for the user")
per the writing-prose indicative-mood rule.
- Dropped "also" from the system-config sentence — it was the only
signal the sentence was an orphan footnote relative to the table above.
- Dropped "Similarly," before the first-commit-prompt sentence; the
parallel "On first run … On first commit …" structure carries the
relation.
- Dropped "Note the single underscore after `WORKTRUNK` and double
underscores between nested keys." that restated what the env-var table
already showed.

**`list.md`** (edits in the `List` command's `after_long_help`) — folded
the three-dot diff detail into the `main…±` column description;
tightened the remaining footnote to just the label-stays-main point.

**`remove.md`** (edits in the `Remove` command's `after_long_help`) —
extracted the cap-detail appendix from the "Patch-id match" bullet,
which had four sentences while the surrounding five bullets averaged one
or two; it now sits as its own paragraph. Also dropped the two sentences
in *Force flags* that inverted the force-flags table just above; only
the new `--no-delete-branch` note remains.

**Skipped** (re-reviewed and judged not worth changing):
- `switch.md` fork material — mechanism + naming-rule, not duplication.
- `step.md` mixed-shape operations list — the asymmetry signals which
subcommands have subdoc sections.
- `step.md` "How it works" subsections — useful reference content, not
internal commentary.
- `step.md` `wt step promote` opener — opinionated voice framing.
- `step.md` `wt step tether` "Why" — now the canonical place for the
leakage rationale (the tips-patterns dup was already removed in #2922).

Auto-synced skill mirrors (`skills/worktrunk/reference/`) and
regenerated help snapshots carry the same edits.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:18:37 -07:00
Maximilian Roos 21e0b27e49 refactor(verbose): rename output.log → subprocess.log; -vv keeps Info on stderr (#2913)
## Motivation

The `-v` / `-vv` UX had three small issues that compounded:

1. **`output.log` is misnamed.** It holds the *uncapped raw
stdout/stderr of every subprocess `wt` spawns* — multi-MB possible (`git
log -p`, patch-id pipelines, etc.). "output" reads as "stuff `wt`
printed" — the small thing — when it's actually the big thing. Easy to
misread.
2. **`-vv` went fully dark on stderr.** PR #2892 moved the noisy debug
pipeline to files at `-vv`; in the process, the stderr layer was
disabled entirely. Users running `-vv` to see hook output (info-level,
which `-v` shows on stderr) suddenly couldn't.
3. **`-v` help text was a 150-char one-liner** packed into a
parenthetical, and the surrounding docs leaned on a "stderr stays
readable / `log::*` pipeline" framing that was Rust-jargon-flavored and
implied stderr-quiet at `-vv` — which is no longer true after change #2.

## Change

- **Rename `output.log` → `subprocess.log`.** Filename now matches
content. `OUTPUT` static → `SUBPROCESS`, plus the related
`OutputMakeWriter` / `OutputFileFormat` / `build_output_layer` symbol
renames.
- **`-vv` keeps the Info baseline on stderr.** `build_stderr_layer` no
longer returns `None` at `-vv`; debug-level records still route to file
layers only, so the terminal stays readable while info-level status
(hook output, template variables, the `Tracing to ...` pointer) shows
the same as at `-v`.
- **`-v` help text rewritten** to describe both levels cleanly without a
wall of detail.
- **`docs/content/faq.md` gets a "What does -v / -vv do?" section** with
a three-level table.
- **Docs cleanup**: drop "stderr stays readable" / `log::*` jargon /
"but not subprocess.log" negative framing from user-facing prose.

## Notes for review

- The only `log::info!` site in the codebase is
`commands/picker/mod.rs:389` (a single picker error message), so making
`-vv` show info-level on stderr doesn't add meaningful noise.
- `test_vv_log_pipeline_silent_on_stderr` is renamed to
`test_vv_debug_pipeline_silent_on_stderr` — its assertions only check
debug-level records stay out of stderr (they do); the old name implied
the whole `log::*` pipeline was silent, which was never quite true
(direct `eprintln!` always showed) and is less true now (info-level
routes to stderr).
- 67 of the 69 changed files are snapshot updates (help text and one
diagnostic snapshot) and auto-synced doc/skill mirrors. `git diff --stat
-- 'tests/snapshots/*' 'docs/content/*' 'skills/worktrunk/reference/*' |
tail -1` separates them.
- CHANGELOG: not touched. The historical entry that introduced
`output.log` (`#2201`) stays accurate for its release; this rename gets
a new line in the next release.

## Tests

3870 tests pass. Re-snapshotted all `test_help_*` snapshots, three
`step_alias` snapshots that quote the global help, and the diagnostic
file format snapshot.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:55:03 -07:00
Maximilian Roos a50785f70a refactor(log): route -vv log pipeline to trace.log instead of stderr (#2892)
## Motivation

`wt … -vv` produced ~15K lines of stderr per invocation — unreadable in
scrollback, forced redirect to a file, at which point the parallel
`trace.log` mirror introduced by #2201 was redundant noise. The earlier
split (#2201) only file-routed the *uncapped raw subprocess bodies*
(`SUBPROCESS_FULL_TARGET`); everything else — `$ cmd` headers,
`[wt-trace]` spans, bounded subprocess previews — stayed on stderr.

## Change

At `-vv`, the `log::*` pipeline routes to `.git/wt/logs/trace.log`
instead of stderr. A one-line startup pointer on stderr tells the user
where it went:

```
○ Tracing to ~/.../trace.log (raw subprocess output @ ~/.../output.log)
```

User-facing `eprintln!` output (status messages, template expansions,
hints) is **unaffected** — it stays on stderr at every verbosity level.
This change governs only the `log::*` macro pipeline.

`-v` is unchanged (Info on stderr, no file). `RUST_LOG=debug` without
`-vv` is unchanged (Debug on stderr, no file — the documented fallback
from #2201).

Result on `wt list -vv`: stderr 15K → 3 lines. `trace.log` gets the full
~1K-line debug trace as before.

## Implementation notes for review

- `src/log_files.rs::route()` is now the only place that picks the sink.
Non-`FULL` targets go to `File(&TRACE)` when `TRACE.is_active()`, else
`Stderr`. Format closure in `src/main.rs` simplified to a `match route`
— no more "mirror to TRACE then write to stderr" branch.
- `Repository::current()` is primed *before* `env_logger.init()` so the
rev-parse fired by `log_files::init` is a memory-cache hit. Records
emitted during priming go to a not-yet-installed logger and are dropped
— robust against future `Repository::current` emissions. A `Ctrl-C`
during the ~5ms priming window can leak one `$ git rev-parse` line;
documented inline, cheaper than violating "all commands through `Cmd`".
- `announce_trace_destination()` handles the rare split-init case where
`output.log` open fails (path-type mismatch, fs quota) but `trace.log`
succeeds — partial pointer + "output.log unavailable" hint. New
regression test `test_vv_pointer_handles_split_init` reproduces the
failure with a pre-existing directory at the `output.log` path.
- `SUBPROCESS_TERMINAL_TARGET` → `SUBPROCESS_BOUNDED_TARGET`. The
"terminal-safe" framing no longer fits now that the bounded preview
lives in `trace.log` rather than stderr.
- `diagnostic.md` doc cutover: three surfaces (`src/diagnostic.rs`,
`src/cli/config.rs`, `docs/content/faq.md`) still claimed "written when
warnings occur" — the code has no warning gate and writes on every
`-vv`. Docs cut over to match reality.

## Deferred follow-ups

Surfaced during review but out of scope here:

- **Unify `trace.log` + `output.log`** — the bounded preview at `-vv`
duplicates a subset of `output.log`'s uncapped bytes. The dual-file
design was explicitly approved for this PR; a follow-up could collapse
them with `diagnostic.md` doing the bounding at extraction time.
- **`RUST_LOG` precedence at `-v`** — pre-existing: `-v 0` honors
`RUST_LOG`, `-v` and `-vv` hardcode the level. Needs a policy decision
(always-honor / always-ignore / merge) more than a cleanup.
- **`tracing` crate migration** — deserves its own dedicated PR with
`[wt-trace]` migration as the headline.

## Tests

3822 tests pass (+1 regression). Notable changes:

- `test_vv_bounded_on_stderr_full_in_output_log` →
`test_vv_log_pipeline_silent_on_stderr` — inverted assertions (marker
must NOT appear on stderr; must appear in `trace.log`). Added a
stderr-pointer presence check.
- `test_vv_pointer_handles_split_init` — new; reproduces the split-init
asymmetry by creating a directory at `output.log`'s path, asserts the
partial pointer fires and `trace.log` still works.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:26:01 -07:00
Maximilian Roos 89ec4f70fa docs(remove): state that --force discards all uncommitted changes (#2869)
The FAQ and the `wt remove --force` help text said `--force` overrides
the untracked-files check "for build artifacts". `--force` actually
removes a dirty worktree including staged and modified *tracked* files,
not just untracked ones (`test_remove_force_with_modified_files`,
`test_remove_force_with_staged_files`). On a destructive command, that
wording can lead users to consent to more data loss than they expected.

Updates the `--force` arg help, the "Force flags" table and example in
`src/cli/mod.rs`, and the FAQ to say `--force` discards staged,
modified, and untracked files; the `help_remove_long` snapshot and the
`remove.md` / `faq.md` doc and skill mirrors are regenerated to match.

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

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:40:41 -07:00
Worktrunk Bot ec62580c29 revert(hooks): keep docs on pre-start/post-start; code accepts both (#2857)
Per @max-sixty's [direction in
#2838](https://github.com/max-sixty/worktrunk/issues/2838#issuecomment-4509447593):
revert the docs portion of #2840 and keep the code. Docs continue to
recommend `pre-start`/`post-start`; both names work in code so anyone
who already followed the briefly-changed docs (e.g. @EcksDy) isn't
stranded once a release ships these aliases.

## User-visible — back to `pre-start`/`post-start`

- README, docs site, skill mirrors, `dev/*.example.toml`,
`plugins/worktrunk/README.md`, `flake.nix`, `.config/wt.toml`
- `src/cli/mod.rs` / `src/cli/config.rs` / `src/cli/step.rs` /
`src/help.rs` after_long_help and example snippets — and the auto-synced
`docs/content/` and `skills/worktrunk/reference/` mirrors
- `wt hook --help` canonical subcommand names; completion advertises
`-start` only
- `HookType` Display via strum, serde `rename`, and clap `ValueEnum`
name — all `pre-start`/`post-start`. The Rust variant identifiers stay
`PreCreate`/`PostCreate` (internal; we already paid for that rename in
#2840, and now the eventual flip is a Display-only change)
- `HooksConfig` serde canonical fields

## `*-create` still works (kept code)

- `wt hook pre-create` / `post-create` — CLI alias on the canonical
subcommand
- `pre-create` / `post-create` in config: top-level, `[hooks.*]`, and
per-project, in string, `[table]`, and `[[array-of-tables]]` form.
Mechanism: serde `alias = ...` on the field, plus a silent in-memory
rename in `migrate_content()` so the round-trip in `unknown_tree`
doesn't flag table forms as schema-unknown.
- The pre-0.32.0 `post-create` fatal-load-error machinery stays removed
— the name is reclaimed, and both forms load without error.

## Smaller bits

- `valid_user_config_keys()` / `valid_project_config_keys()` append
`pre-create` / `post-create` so the unknown-field round-trip skips them.
`test_valid_*_keys_all_deserialize` skips both aliases (they can't sit
alongside the canonical without a duplicate-field error).
- `DEPRECATED_SECTION_KEYS` drops the `pre-start`/`post-start` entries
#2840 added — `pre-start`/`post-start` are canonical again.
- `find_pre_start_from_doc` / `find_post_start_from_doc` /
`find_renamed_hook_key` / `is_non_empty_item` /
`migrate_start_hooks_doc` and their tests are removed; the migration
direction flips via a new `migrate_create_hooks_doc` (silent, mirrors
the prior shape).
- Test files `e2e_shell_post_create.rs` and `post_create_commands.rs`
rename back to `_post_start_` (via `git mv`, so the rename shows as a
rename).

## Testing

`cargo run -- hook pre-merge --yes` — 3806 tests pass; the 10 failures
are all `case_4` of `shell_wrapper::unix_tests::*` (nu-shell case; `nu`
isn't installed in this runner; same failures occur on `main`).

Also manually verified that a fresh `wt switch --create` against a
project config with `[post-create]` loads cleanly with no unknown-field
warning and the hook fires as `post-start`.

## Follow-up

Per @max-sixty: in a couple of weeks, once a release with
both-names-work is out and users have had a chance to upgrade, the docs
flip is straightforward (most of it is in `src/cli/mod.rs`'s
`after_long_help` and the doc-sync test propagates).

Re #2838.

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 16:03:03 +00:00
Maximilian Roos d7e3f88422 feat(hooks): rename worktree-creation hooks to pre-create/post-create (#2840)
Phase 1 of the staged hook rename tracked in #2838: the worktree-creation hooks `pre-start`/`post-start` become `pre-create`/`post-create`. The old names keep working with no deprecation warning yet (Phase 2, months out, adds the warning).

## What changes

- `pre-create`/`post-create` are canonical everywhere: the `HookType` enum, the `HooksConfig` serde fields, the `wt hook` CLI, completion, and all docs.
- Old names keep working: `migrate_content()` rewrites `pre-start`/`post-start` config keys to `-create` before serde, and `parse_hook_type` accepts the old CLI names as silent aliases. `wt config update` rewrites them on disk; `wt config show` shows the migration diff. `wt hook <type>` execution and `wt hook show` both accept the old names; completion and `--help` advertise only the canonical names.
- `detect_deprecations()` flags the old keys so `update`/`show` act on them, but `format_deprecation_warnings()` stays silent (Phase 2 adds the warning). A new empty-warnings guard in `check_and_migrate` keeps a `-start`-only config from emitting a stray hint.
- The dead pre-0.32.0 `post-create` machinery is removed: the fatal `POST_CREATE_REMOVED_MSG` load error, the vestigial `HooksConfig.post_create` merge-fold, and `find_post_create_from_doc`. `post-create` is reclaimed as the canonical background creation hook.

## Semantic flip

Before v0.32.0, the key `post-create` named a *blocking* hook. It now names the *background* one.

Since v0.44.0, a pre-0.32.0 `post-create` config is a fatal load error on the `check_and_migrate` paths: `ProjectConfig::load` and user/system config loading, which fire on essentially every `wt` command. A repo carrying one has been unusable ever since. The one path that skips that check is `project_config_at_ref` (the base-ref read behind `wt switch --create`), which applies only structural migration. A pre-0.32.0 `post-create` surviving solely on a base ref, never checked out into a worktree, would now load as a background hook rather than folding into the blocking `pre-start`. That edge case is accepted: once `post-create` is valid again, reclaiming the name and detecting the dead key are mutually exclusive.

## Reviewing this diff

205 files, but the substance is ~36 files under `src/`. The rest is regenerated snapshots and auto-synced doc mirrors. Start with:

- `src/config/deprecation.rs` — detection (`find_renamed_hook_key`), migration (`rename_hook_key`), removal of the fatal block, the empty-warnings guard, and the `DEPRECATED_SECTION_KEYS` entries that stop unknown-field detection from flagging the migrated keys.
- `src/config/hooks.rs`, `src/git/mod.rs` — the serde field and enum renames.
- `src/config/project.rs` — `ProjectConfig::load` deserializes `check_and_migrate`'s migrated content, so a current-worktree config using the old keys loads into the canonical fields.
- `src/cli/hook.rs`, `src/commands/hook_commands.rs`, `src/completion.rs`, `src/main.rs` — the CLI alias layer; `wt hook show` accepts the old type names as hidden value-parser aliases.
- `src/cli/mod.rs` — the `wt hook` docs, including the soft-deprecation note linking #2838.

The ~93 modified snapshots also pick up deterministic env-block lines (`GIT_*: ""`, `LLVM_PROFILE_FILE`) that pre-existing snapshots already carry. That is stale-snapshot drift surfaced by the regeneration, not a behavior change.

## Testing

Full suite green (3799 tests). New coverage: `snapshot_migrate_start_to_create` (migration preserves value shape and position), `test_deprecated_start_hook_key_runs_silently` and `test_standalone_hook_start_alias_runs_silently` (old config and CLI names run with no warning), `test_config_show_displays_start_hook_migration` (`config show` reveals the diff without an "unknown field" warning), and `test_hook_show_accepts_deprecated_start_hooks` (a current-worktree config using the old keys loads, and `wt hook show` takes both the canonical and the deprecated type arguments).

Part of #2838.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-20 19:31:50 -07:00
Maximilian Roos ace32e0e2c fix: reap orphaned fsmonitor daemons in wt remove internal sweep (#2814)
## Why

Companion to #2813. That PR stops the fsmonitor-daemon leak at its
source (`wt remove` force-terminating a wedged daemon). This PR is
defense-in-depth for daemons orphaned by paths that bypass `wt remove`
entirely — plain `git worktree remove`, a manual `rm -rf`, or a crashed
`wt` — which #2813 cannot cover. Background: a wedged `git
fsmonitor--daemon` makes `git status` (and `wt list`) hang; orphans had
accumulated ~80-deep on one machine.

## What

Adds an orphan sweep (`src/git/fsmonitor.rs`: `classify_orphans`,
`reap_orphan_fsmonitor_daemons`) hooked into the **existing** `wt
remove` internal sweep op — no new always-on/timer mechanism, and
deliberately not wired to `wt list` or any read-only command (killing
processes as a side effect of a read-only command is out of bounds). It
reaps only daemons whose IPC socket resolves to a worktree that no
longer exists, SIGTERM → bounded wait → SIGKILL, socket-scoped via `lsof
-a -p <pid>` (never broad process-name matching). A daemon serving a
*live* worktree is never reaped — proven by unit tests, and the
invariant holds even when the live-worktree set is unknowable:
`live_git_dirs` returns `Option`, and an unknowable set disables
resolved-socket reaping entirely rather than falling back to "reap
everything".

Shares low-level fsmonitor mechanics with #2813 and both touch `faq.md`
/ `troubleshooting.md` / `CHANGELOG.md`; whichever of the two lands
second needs a small dedup and doc reconciliation.

## Testing

`wt hook pre-merge --yes` green (3757/3757). 11 deterministic unit tests
cover the pure logic, including `live_worktree_daemon_is_never_reaped`
and `unknowable_live_set_spares_resolved_socket_daemons`.

`src/git/fsmonitor.rs` is a new file, so it is entirely in patch scope:
94.5% line / 95.4% region. The uncovered lines are real-OS-signal and
live daemon-enumeration paths only reachable by spawning real `git
fsmonitor--daemon` processes; their pure logic is exhaustively
unit-tested. A real-daemon end-to-end test was prototyped and dropped as
flaky and net-negative (daemon-spawn races; risk of SIGKILLing unrelated
dev-machine daemons). These uncovered OS-only lines are an accepted
trade-off — if `codecov/patch` flags them it is a known false positive,
not a gap to fill with a fragile test.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 23:48:58 +00:00
Maximilian Roos 045750f890 fix(remove): force-kill a wedged fsmonitor daemon on worktree removal (#2813)
## Why

`wt remove` already calls `git fsmonitor--daemon stop` before staging a
worktree to trash, but that stop is an IPC request to the daemon itself.
When the daemon is *wedged* — the common failure that makes `git status`
(and therefore `wt list`) hang — it can't answer the IPC, so the stop is
a silent no-op and the daemon leaks. In practice this accumulated ~80
orphaned `git fsmonitor--daemon` processes on one machine, one of which
wedged and hung `wt list` outright.

## What

Adds a canonical `stop_fsmonitor_daemon` helper: after the IPC stop, it
resolves the daemon's PID from its IPC socket and escalates SIGTERM →
bounded wait → SIGKILL, scoped strictly to the removed worktree's own
socket (never matched by process name, never another worktree's daemon).
All three removal paths (library, foreground, detached-background) route
through the one helper; the redundant `git ... fsmonitor--daemon stop`
shell fragment in the detached path is deleted so there is a single
canonical stop. Force-kill is Unix-only with a clean `#[cfg(not(unix))]`
no-op (Windows keeps the IPC stop, where the daemon uses a named pipe).

This is the source-level fix for the leak on the removal path. Companion
PR #2814 adds a defense-in-depth sweep for daemons orphaned by paths
that bypass `wt remove` (plain `git worktree remove`, manual `rm`, a
crashed `wt`). The two branches share low-level fsmonitor mechanics and
both touch `faq.md` / `troubleshooting.md` / `CHANGELOG.md`, so
whichever lands second will need a small dedup and doc reconciliation.

## Testing

Unit tests cover the deterministic logic: git-dir/socket resolution for
a linked worktree, the SIGTERM-honored fast path, and SIGTERM-ignored →
SIGKILL escalation (with a readiness handshake to avoid a
signal-vs-trap-install race). `wt hook pre-merge --yes` is green. The
`lsof`-failure and git-dir-error arms are single best-effort
`log::debug!`+return lines that fall through to the existing fail-open
behavior.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 16:30:19 -07:00
Maximilian Roos 0b088b5790 perf(list): cache ahead/behind counts SHA-keyed; skip the for-each-ref walk on warm runs (#2704)
## Summary

`wt list --branches` computed branch ahead/behind counts with one `git
for-each-ref --format='%(ahead-behind:BASE)'` walk, on every invocation,
in the post-skeleton setup scope. That walk is O(commits) — ~1.5s on
rust-lang/rust — and it runs *serially before the parallel task pool
opens*, because the per-branch tasks consume its result. So on a large
real repo roughly 40% of `wt list`'s wall time was a single-threaded git
graph walk that thread count couldn't help with (a follow-up from the
picker thread-count investigation in #2683/#2685).

Ahead/behind for `(base, branch)` is a pure function of the two commit
SHAs — content-addressed, never stale — exactly the shape of the
existing `.git/wt/cache/` SHA-keyed caches. This adds an `ahead-behind/`
cache kind and rewires the population path:

- New `ahead-behind/{base_sha}-{head_sha}.json` kind in `sha_cache.rs`,
identical machinery to `is-ancestor` / `diff-stats` (LRU-bounded,
cleared by `wt config state clear`, wiped by the bench
cache-invalidator).
- `Repository::ahead_behind_by_sha` is now cache-backed: check →
`compute_ahead_behind` → write.
- `RefSnapshot::capture_refs_with_ahead_behind` builds the snapshot's
ahead/behind map from the cache and only walks the graph for the
branches the cache doesn't cover:
- **a few misses** → left out of the snapshot map; the per-branch
`AheadBehindTask` recomputes (and caches) them by SHA in the parallel
pool. (That task already runs `merge_base_by_sha` for its orphan check,
so the merge-base `compute_ahead_behind` needs is already primed — the
fallback is just two cheap `rev-list --count` calls.)
- **everything cold** (fresh repo / after `wt config state clear`) → one
unscoped `for-each-ref %(ahead-behind:BASE_SHA) refs/heads/` walk — a
single shared traversal finds every merge-base — then results written to
the cache.
- **many cold but not all** → one `for-each-ref
%(ahead-behind:BASE_SHA)` *scoped to just the missed refnames* (`git
for-each-ref` takes a ref list, so it's still one shared traversal, only
over the cold subset), results written to the cache.

The few-vs-many threshold (`AHEAD_BEHIND_SCOPED_BATCH_MIN_MISSES`)
trades the per-pair path's parallelism (it runs in the pool) against the
batch's lower total work (one shared base-history traversal); a batch
here is serial, so it's only worth it once "many" misses make the
amortization clear.

### Cache-correctness details (Codex-prompted)

A few sharp edges, surfaced by `/review-codex`, that the seeding path
now handles explicitly:

- **`%(ahead-behind)` computed against the *resolved SHA*, not the
refname.** A tag named `main` shadows the branch in git's ref
resolution; passing `base_sha` to the batch makes it inert to that. The
fallback path (default branch isn't a local/remote-tracking ref) still
passes the refname and caches nothing.
- **Cache key from the batch's `%(objectname)`, not a separately-scanned
value.** If a `refs/heads/X` moved between `scan_locals` and the batch
(a sub-ms window, but possible), the counts are for the *new* SHA — and
the cache now stores them under that SHA, not under the staler
`b.commit_sha`.
- **Orphan branches normalized to `(0, 0)` before writing.** Git's
`%(ahead-behind:BASE)` for an orphan (no common ancestor) prints the two
disjoint history sizes; `compute_ahead_behind` returns `(0, 0)` and
signals orphan-ness separately. An orphan's `behind` count equals base's
total commit count, so one `git rev-list --count base_sha` on the
seeding path detects them. The cache invariant — cache hit equals what a
miss would recompute — holds for orphans now.
- **Bulk-write avoids O(N · dir_size) on the seeding path.**
`write_with_lru` scans the cache directory on every call to enforce the
LRU bound; the serial setup-scope seeding loop calls it N times.
`put_ahead_behind_bulk` writes all N entries first and sweeps once at
the end.

### Net effect

| | before | after |
|---|---|---|
| warm `wt list` on a large repo | ~1.5s serial `for-each-ref
%(ahead-behind)` in the setup scope | N small cache reads; no graph
walk; no serial blocker |
| `wt list` after committing on one branch | full ~1.5s walk again
(batch op — any change re-walks all) | only that branch recomputed (two
cheap `rev-list --count`, in the pool); the rest stay cached |
| many branches moved since last list | full walk | one `for-each-ref
%(ahead-behind)` scoped to just the moved ones |
| cold (fresh repo / after `state clear`) | one combined walk |
unchanged — one combined walk + one bulk cache seed |
| skeleton time (`WORKTRUNK_SKELETON_ONLY`) | — | unaffected (returns
before the scope this touches) |

No user-facing behavior change — same numbers, same columns. The picker
(`wt switch`) shares this path, so its preview pre-compute benefits
identically.

### Follow-ups left as TODOs in the code

- `TODO(ahead-behind-pool)` (`src/commands/list/collect/mod.rs`): the
cold-cache `%(ahead-behind)` walk still runs serially in the setup
scope, blocking the task pool from opening. Nothing downstream of
work-item generation needs the *counts* (only the per-row
`AheadBehindTask`, which has a per-SHA fallback) — only the cheap ref
scan does. So the walk could become a single work item in the pool,
overlapping the other ~N workers. Needs an inter-task dependency the
work-item model doesn't have today.
- `TODO(remote-ahead-behind-batch)`
(`src/commands/list/collect/tasks.rs`): the `Remote⇅` column's
per-branch `ahead_behind_by_sha(upstream, branch)` is already
cache-backed by this change, but has no cold-start batch primer. One
`for-each-ref --format='%(refname) %(upstream:track,nobracket)'
refs/heads/` would fill the `ahead-behind/` cache for it in a single
walk, mirroring what `%(ahead-behind:main)` does for `main↕`.

## Testing

- `sha_cache.rs`: round-trip + cache-read tests for the new kind;
`test_clear_all_covers_all_kinds` extended.
- `ref_snapshot.rs`: all-cold→unscoped-batch + cold→warm second-capture
reads the persistent cache; partial-warm (few misses) omits the moved
branch; many-misses uses the scoped batch; **orphan normalizes to
`(0,0)` in both the snapshot and the cache file**; remote-tracking base
resolves; unresolvable base degrades cleanly.
- Smoke-tested manually: `wt list --branches` populates
`.git/wt/cache/ahead-behind/`; tampering an entry → the next run shows
the tampered counts (proves the read path).
- Two passes of `/review-codex` — second pass clean.
- `cargo run -- hook pre-merge --yes` — full suite + lints + doc-sync
(3595 tests).

## Out of scope (flagged, not done here)

`docs/dev/cache-staleness.md` still references the in-memory
`RepoCache.ahead_behind` field and `batch_ahead_behind` — both removed
when `RefSnapshot` landed. Pre-existing staleness; rewriting that doc is
a separate cleanup. (The pre-skeleton `git log --no-walk` commit-details
batch is also content-addressed by SHA and could in principle be
cache-first, but it's already O(refs) and fast — not worth a TODO.)

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:27:15 -07:00
Worktrunk Bot 6d0588de3c docs(faq): document new summary cache layout (#2408)
## Summary

PR #2407 added the content-addressed summary cache at
`.git/wt/cache/summary/{branch}/{hash}.json`, but the FAQ inventory in
['What files does Worktrunk
create?'](https://worktrunk.dev/faq/#what-files-does-worktrunk-create)
only listed the flat `.git/wt/cache/{kind}/*.json` shape. That row
doesn't capture the nested layout (`{branch}/{hash}.json` instead of
flat) or the LLM-summary purpose, so the on-disk surface now exceeds
what the docs admit.

Add a dedicated row matching how `wt config state get` already
surfaces summaries as a separate `SUMMARY CACHE` table.

Caught while reviewing recent commits in the nightly sweep.

## Test plan

- [x] `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync` — passes (skill mirror
auto-synced)
- [ ] CI green

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 08:45:29 -07:00
Maximilian Roos 64e1b4efb9 feat(cli): move approvals subcommand from hook to config (#2282) 2026-04-18 00:46:27 -07:00
Maximilian Roos 07db111340 docs: trim filler sentences from prose docs (#2271)
Remove sentences that restate obvious fallback/error behavior, duplicate
nearby prose, or add visual weight without information.

- `extending.md` — drop the "not a wt command" error note, the
experimental badge on Aliases, and a duplicate recap of the
`cd`-to-parent-shell behavior at the end of a recipe.
- `faq.md` — drop "The result is cached for fast subsequent lookups"
padding after the default-branch detection explanation.
- `tips-patterns.md` — drop "Creates a worktree that builds on the
current branch's changes" (restated by the section heading) and
"Sessions are named after the branch for easy identification" (visible
in the code).

Skill reference files auto-synced via
`test_command_pages_and_skill_files_are_in_sync`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:34:48 -07:00
Maximilian Roos 016eb030e7 docs(extending): rename "external subcommand" to "custom subcommand" (#2270)
## Summary

Renames the git-style `wt-<name>` dispatch feature to "custom
subcommand" across user-facing docs and internal code. Two motivations:

- **Avoid overloading "external."** The codebase already uses "external
command" for the `shell_exec` concept (subprocesses like `git` and
`gh`). Using the same word for the `wt-foo` dispatch feature is
ambiguous.
- **Cargo uses "custom command" / "external subcommand"
interchangeably.** kubectl calls theirs "plugins," gh calls theirs
"extensions"; git doesn't have a settled term. "Custom subcommand" reads
naturally in prose and matches cargo's user-facing phrasing.

## Changes

**User-facing docs** — `docs/content/extending.md` (section heading,
description, comparison table) and `docs/content/faq.md` (link +
anchor). Skill references auto-sync.

**Internal code** — `src/commands/external.rs` → `custom.rs`,
`Commands::External` → `Commands::Custom`, `handle_external_command` →
`handle_custom_command`, plus matching renames in `src/completion.rs`
(inject/discover/forward functions) and corresponding tests. Also
renames `tests/integration_tests/external.rs` → `custom.rs`.

**Kept intact** — clap's `#[command(external_subcommand)]` attribute and
`.allow_external_subcommands(true)` are clap's own vocabulary, not ours.

CHANGELOG is historical and left unchanged.

## Test plan

- [x] `cargo build` clean
- [x] `cargo clippy --all-targets --all-features` clean
- [x] `cargo test --lib --bins` — all pass
- [x] `cargo test --test integration` — all pass (1476)
- [x] `pre-commit run --all-files` clean

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-16 22:06:12 -07:00
Maximilian Roos 356bec16a0 feat(cargo): add cli feature to unbundle CLI-only deps (#2238) 2026-04-14 22:40:39 -07:00
Worktrunk Bot 9884cdcf86 docs: link worktrunk-sync in Extending and FAQ (#2225)
## Summary

Requested in max-sixty/worktrunk#2053 — link to
[`worktrunk-sync`](https://github.com/pablospe/worktrunk-sync) from the
docs.

- **Extending Worktrunk** — adds an `### Examples` subsection under
*External subcommands*, pointing to `worktrunk-sync` as a concrete
real-world use of the `wt-<name>` dispatch mechanism.
- **FAQ** — adds a "Does Worktrunk support stacked branches?" Q&A
positioned after the tool-comparison section, explaining that stacked
workflows are deliberately kept out of core and pointing readers at
`worktrunk-sync`.

Skill reference files under `skills/worktrunk/reference/` regenerated
via `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync`.

## Test plan

- [x] `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync` passes
- [ ] Prose/links render as expected on the dev server

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:38:00 -07:00
Maximilian Roos 3e1b351ac1 feat(log): split -vv output into trace.log + output.log, drop -vvv (#2201)
## Motivation

At `-vv`, raw subprocess bodies were bounded (200 lines / 64 KB) on both
stderr and `verbose.log`, with full output only available by rerunning
at `-vvv`. Large captures (notably `git log -p` piped into `patch-id`
during `wt list`) would routinely flood stderr with elision markers and
force a second run just to see what was elided.

## Change

The verbosity map is now `-v` (info) and `-vv` (debug); any `-v` count
above 2 collapses to `-vv`. Captured subprocess stdout/stderr fan out
through two log targets in `src/shell_exec.rs`:

- `SUBPROCESS_TERMINAL_TARGET` → bounded preview on stderr, mirrored to
`.git/wt/logs/trace.log` (new, replaces `verbose.log`)
- `SUBPROCESS_FULL_TARGET` → uncapped body to `.git/wt/logs/output.log`
(new), never stderr

`src/log_files.rs` (renamed from `src/verbose_log.rs`) owns both file
sinks behind a `LogSink` type and a `route(target)` helper that is the
single source of truth for sink selection. `src/main.rs`'s env_logger
format closure matches on the `Route` enum and emits once per sink.
Diagnostic reports embed `trace.log` and reference `output.log` by path
— multi-MB raw bodies would swamp a bug report.

## Fallback path

When `RUST_LOG=debug` is set without `-vv`, neither sink is active.
`FULL` records drop and the `TERMINAL` preview reaches stderr as before
— preserving the bounded-stderr guarantee. The elision marker phrases
its hint based on whether `output.log` was opened, so users in the
fallback path see `rerun with -vv for full output` rather than a pointer
to a file that doesn't exist.

## Key files

- `src/log_files.rs` — new module; `LogSink`, `TRACE`, `OUTPUT`,
`route`.
- `src/shell_exec.rs` — two `pub const` targets, `log_output` emits on
both, elision hint switches on `OUTPUT_LOG_AVAILABLE`.
- `src/main.rs` — verbosity map + format closure.
- `src/diagnostic.rs` — template splits inlined `trace.log` from
referenced `output.log`.
- `src/commands/config/state.rs` — diagnostic file recognition for the
new names.

## Testing

- `test_vv_splits_full_and_bounded_output` — `[wt-trace]` in
`trace.log`, raw stdout in `output.log`, no trace records in
`output.log`.
- `test_vv_bounded_on_stderr_full_in_output_log` — 250-ref packed-refs
trip the elision cap; asserts the marker on stderr + `trace.log`, full
content in `output.log` without elision.
- `test_rust_log_debug_fallback_without_vv` — no log files created at
`-v 0 + RUST_LOG=debug`; bounded preview reaches stderr.

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

---------

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

## What changed

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

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

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

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

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

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

## Testing

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

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

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:58:03 -07:00
Maximilian Roos 7003edb72b Persistent cache for merge-tree and integration probe results (#2085)
Caches expensive SHA-keyed git operations (merge-tree conflicts,
integration probe) to disk under `.git/wt/cache/{kind}/`. Commit SHAs
are content-addressed, so cached entries never go stale — only an LRU
size bound (5000 per kind) prevents unbounded growth.

The cache is transparent: `has_merge_conflicts` and
`merge_integration_probe` check/populate it internally, so list, picker,
merge, and remove all benefit without code changes. Refs are resolved to
SHAs before all git commands, ensuring the cache key matches the actual
computation.

Layout: one JSON file per entry at
`.git/wt/cache/{kind}/{sha1}-{sha2}.json`, with symmetric keys for
conflict detection and asymmetric keys for integration probe. Corrupt
entries degrade to cache miss (no tmp-and-rename needed). Cleared by `wt
config state clear`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 13:36:18 -07:00
Maximilian Roos f3ec618e82 docs(faq): use git config worktrunk.* for config key location (#2086)
The previous wording (`.git/config` keys under `worktrunk.*`) mixed
plain text between two code spans in the Location column. The
`td:first-child` CSS rule sets `font-weight: 550`, so "keys under"
rendered semi-bold while the flanking `<code>` elements stayed at 400 —
visually inconsistent with every other row.

`git config worktrunk.*` is a single code span that also avoids tying
the location to a specific config file (`.git/config` vs
`~/.gitconfig`).

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 13:19:14 -07:00
Maximilian Roos 000613ccfd docs(faq): qualify "no background processes" claim (#2080)
Hooks and worktree removal spawn short-lived detached processes, so the
blanket "no background processes" was inaccurate. Adds "long-running"
qualifier.

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 12:53:39 -07:00
Maximilian Roos a9f3803452 fix(docs): use quote placeholders in terminal shortcode (#2081)
Tera has no backslash-escape for string literals, so `\"` inside a `cmd`
parameter closes the string prematurely. The `git worktree lock` example
in the FAQ was rendering as literal `{{ terminal(...) }}` text instead
of a styled terminal block.

Switched to the `__WT_QUOT__` placeholder that the shortcode template
already handles (replacing back to `"` before Syntect highlighting).

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 12:48:36 -07:00
Maximilian Roos f8f291b372 refactor(logs): nest hook output by branch/source/hook-type/name (#2041)
Flattens categorization of `.git/wt/logs/` by making the filesystem
encode what the old scheme crammed into filenames.

**Layout:** top-level *files* are shared logs (`commands.jsonl*`,
`verbose.log`, `diagnostic.md`); top-level *directories* are per-branch
log trees — `{branch}/{source}/{hook-type}/{name}.log` for hook output,
`{branch}/internal/remove.log` for background removal,
`wt/internal/trash-sweep.log` for the trash sweeper. Categorization
becomes a trivial file-vs-directory check, eliminating the exclusion
rule `ends_with(".log") && !is_diagnostic_file(name)`.

**Wins:** per-branch listing/clearing is now O(that branch) instead of
O(all logs); orphan cleanup for a removed branch is a single
`remove_dir_all`; filenames drop the joined-tuple collision hashes they
only needed to disambiguate flat keys.

**Transition:** `clear_logs` keeps a self-healing sweep of legacy
top-level `.log` files so users transition without an explicit
migration. A pinning test
(`test_state_clear_logs_sweeps_legacy_flat_files`) guards that behavior.

**Observable change:** `logs get --format=json` now puts relative paths
in the `file` field (e.g. `main/user/post-start/server.log`). Log
locations are listed as "flexible" in `CLAUDE.md`, so this is in scope.

**Reviewer orientation:**
- `src/commands/process.rs` — `HookLog::path()` rewritten; `suffix()` /
`filename()` deleted.
- `src/commands/config/state.rs` — new `walk_hook_output_files` /
`walk_branch_dir` / `HookOutputEntry`; `clear_logs` handles legacy
sweep; `partition_log_files_json` + `render_*` split along the top-level
vs hook-output seam; module docstring pins the invariant.
- `src/testing/mod.rs` — `wait_for_file_count` walks recursively.
- Test fixtures in `tests/integration_tests/config_state.rs` use new
`hook_log_rel_path` / `internal_log_rel_path` / `write_log_at` helpers.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-04-09 16:47:35 -07:00
Maximilian Roos 4a7219b230 Sweep stale .git/wt/trash entries on wt remove (#2039)
## Summary

Background `wt remove` renames worktrees into
`.git/wt/trash/<name>-<timestamp>` before firing a detached `rm -rf`. If
that cleanup is interrupted (SIGKILL, reboot, disk full), the directory
is orphaned and nothing reclaims the space.

After `wt remove` prints its primary output, it now sweeps trash entries
older than 24 hours via a single detached `rm -rf`. Age is parsed from
the filename suffix, so the sweep is deterministic under
`WORKTRUNK_TEST_EPOCH`.

## Notes

- Runs **after** `handle_remove_output` so it never delays
time-to-first-output. `.claude/skills/writing-user-outputs/SKILL.md`
documents the general rule for future handlers.
- Uses existing `spawn_detached` infrastructure and a new
`InternalOp::TrashSweep` log name (`.git/wt/logs/wt-trash-sweep.log`).
- Unparseable entries are left alone — the sweep only touches names
worktrunk produced.
- Integration test seeds stale + fresh entries, runs `wt remove`, and
polls for the stale entry to disappear while the fresh one stays.

Ref #1974 — related safety improvement (data lingers in trash briefly
before deletion), but doesn't resolve the prune confirmation prompt
discussion there.

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-09 13:54:41 -07:00
Maximilian Roos 045fd25941 docs: fix inaccurate logs documentation (#1986)
The `wt config state logs` documentation had several factual errors
discovered during an audit:

- **Wrong scope label**: "Background operation logs" was the subcommand
definition, but the directory also contains diagnostic files
(`verbose.log`, `diagnostic.md`) from `-vv` — not background operations.
Changed to "Operation and debug logs".
- **Wrong behavior for `commands.jsonl`**: The "Behavior" section said
"Overwrites — Same operation on same branch overwrites previous log" but
`commands.jsonl` appends (with rotation at 1MB). Removed the blanket
section, moved accurate behavior inline to each subsection where it
applies.
- **FAQ table overlap**: `*.log` glob matched `verbose.log`,
contradicting the explicit `verbose.log` row two lines below. Narrowed
to `{branch}-*.log`.
- **Undocumented JSONL fields**: Docs said "timestamp, command, exit
code, and duration" but the actual fields are `ts`, `wt`, `label`,
`cmd`, `exit`, `dur_ms` — the `wt` and `label` fields were missing.
Added a field table since the docs encourage `jq` queries.
- **Incomplete `clear` description**: FAQ said `wt config state clear`
"removes all worktrunk keys from `.git/config`, deletes CI cache, clears
logs, and removes stale trash" — omitting markers, hints, variables, and
previous-branch.
- **Inaccurate hook output filename pattern**: Table showed
`{branch}-{source}-post-start-{name}.log` but actual filenames include
hash suffixes from `sanitize_for_filename()` and cover all `post-*` hook
types, not just post-start.
- **Dead code cleanup**: Removed unreachable `"logs"` arm from
`handle_state_get` — `StateCommand::Logs` routes directly to
`handle_logs_get`.

Also includes the `state-logs-docs` branch changes:
`is_diagnostic_file()` filter so `logs get` and `logs clear` handle
`diagnostic.md`, `render_log_section()` generic helper, and three-way
partition in the JSON output.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-07 15:37:27 -07:00
Maximilian Roos ed883d5747 fix: document and categorize diagnostic files in state logs (#1981)
`verbose.log` and `diagnostic.md` (created by `-vv`) were undocumented
in the `wt config state logs` help text and FAQ. `verbose.log` was
miscategorized as hook output in `logs get`, and `diagnostic.md` was
invisible to both `logs get` and `logs clear` (not matched by
`is_wt_log_file`).

Adds a DIAGNOSTIC section to all output paths (human table, JSON, `logs
get`, `state get logs`), updates help text ("two kinds" → "three
kinds"), and updates the FAQ file inventory.

Also extracts the three identical per-section render functions into a
single `render_log_section` parameterized by heading and filter
predicate — `render_all_log_sections` composes them and is called from
all three display paths.

Fixes a test filter bug where `\s*$` consumed trailing newlines,
collapsing blank line separators between populated sections. Changed to
`[ \t]*$` to match only horizontal whitespace.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-07 13:03:54 -07:00
Maximilian Roos 850961366d feat: clean up stale trash in wt config state clear (#1960)
Worktree removal renames directories into `.git/wt/trash/` for instant
UX, then deletes them in a background `rm -rf`. If the background
process fails or is killed, entries accumulate indefinitely — discovered
22 GB of stale trash from two entries whose background cleanup never
completed.

Adds trash cleanup to `wt config state clear`, which already handles CI
cache, logs, markers, hints, and vars. Also updates the FAQ to document
`.git/wt/trash/` in the "What files does Worktrunk create?" table and
the "Other cleanup" description.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-06 19:09:52 -07:00
worktrunk-bot 4972fd8b02 docs: fix FAQ reference to approvals storage location (#1886) 2026-04-02 09:31:24 -07:00
Maximilian Roos 69edc3a337 feat: add wt config state vars for per-branch custom variables (#1006)
Adds `wt config state vars set/get/list/clear` commands for storing
custom variables per branch in git config
(`worktrunk.state.{branch}.vars.{key}`). Variables are available in all
template contexts via `{{ vars.key }}` syntax (hooks, `wt step eval`),
with JSON dot access (`{{ vars.config.port }}`) and default filters (`{{
vars.env | default('dev') }}`).

Uses `KEY=VALUE` syntax for `set` — `wt config state vars set
env=staging` — matching Docker `-e`, Heroku `config:set`, Fly `secrets
set`, and worktrunk's own `--var KEY=VALUE` convention. Splits on first
`=` only, so values can contain `=` (URLs, JSON).

The database-per-worktree example now uses `vars` to store the
connection string during `post-start`, replacing the `.env.local`
heredoc pattern. The URL is accessible outside hooks via `$(wt config
state vars get db-url)`.

Includes vars data in `wt list --format=json` output and `wt config
state get` display. Builds on #1004 (`wt step eval`). Part of #947.

## Test plan

- [x] Unit tests for vars template injection (empty, with data, no
branch, JSON dot access, shell escaping)
- [x] Integration tests for vars CLI commands (set, get, list, clear,
clear --all, --branch flag)
- [x] Edge-case tests for KEY=VALUE parsing (values containing `=`,
empty values)
- [x] Integration tests for vars in JSON output (present with data,
absent when empty)
- [x] All 493 unit + 1294 integration tests pass

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-30 14:37:04 -07:00
Maximilian Roos b6a6744380 Consistent code block convention for syntax highlighting (#1777)
Shell code blocks on the docs site had inconsistent syntax highlighting.
Blocks with `$ ` prompt prefixes rendered as a single flat color because
Syntect's bash grammar treats `$` as variable expansion. This PR adds `$
` prompts to all shell commands while preserving full Syntect
highlighting by routing through terminal shortcodes.

## Approach

All shell commands in `console` blocks use `$ ` prefix.
`convert_dollar_console_to_terminal()` (new library function in
`src/docs.rs`) detects `$ ` lines and emits Zola terminal shortcodes:

- **Single or multi-command blocks** (no `{{ }}`): Uses `cmd` parameter
with `|||` delimiter. The shortcode template splits, highlights each
line individually through Syntect, and wraps commands in `<span
class="cmd">` (CSS `::before` adds `$ `). Comment lines (`#`) are
highlighted as comments without a prompt.
- **Blocks with `{{ }}` template syntax**: Falls back to body approach
with `<span class="cmd">` (accent color only, since Tera would interpret
`{{ }}` in the `cmd` parameter).

The function runs in both the `--help-page` generator (CLI source →
docs) and the doc sync test (hand-written docs → terminal shortcodes).
Hand-written docs can use plain `console` fences with `$ ` and get
auto-converted.

## Key files

- `src/docs.rs` — New library module with
`convert_dollar_console_to_terminal()` and unit tests
- `docs/templates/shortcodes/terminal.html` — Template enhanced to loop
over `|||`-delimited commands, highlighting each through Syntect.
Supports self-closing `{{ }}` syntax for bodyless blocks.
- `src/help.rs` — Uses library function, updated pipeline docs
- `tests/integration_tests/readme_sync.rs` — Sync test runs conversion
on all docs (not just CLI-generated). Updated skill transformation to
handle both body and self-closing terminal shortcodes.
- All `src/cli/*.rs` — `$ ` added to all console blocks
- All `docs/content/*.md` — Auto-converted to terminal shortcodes (zero
`bash` blocks remain)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <w@worktrunk.dev>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-03-28 12:18:48 -07:00