Commit Graph

1582 Commits

Author SHA1 Message Date
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 b17a1364f1 docs(step): render wt step rebase & wt step push on the docs site (#3578)
Follow-up from #3568, which named these two as the one plain oversight
in its audit of `after_long_help` bodies that never reach a docs page.

`wt step rebase` and `wt step push` were the only two of twelve step
operations with no `<!-- subdoc: -->` marker, so their help was
terminal-only, and the only two bullets in `## Operations` left
unlinked. Both markers are added between `squash` and `diff`, per the
ordering invariant in `src/cli/step.rs`.

Both openers restated their `about` line, which reads as immediate
self-repetition on the web where `combine_command_docs()` concatenates
the two. Both bodies are rewritten.

## What reviewing the text against the code turned up

The old rebase body said "Conflicts abort immediately; use `git rebase
--abort` to recover", which is self-contradictory and wrong. Nothing
aborts: the worktree is left mid-rebase with git's conflict markers, and
git's output names `--continue`, `--skip`, and `--abort`. `wt merge`'s
pipeline step 3 carried the same sentence.

Four review passes then falsified nine more claims, every one reproduced
against a scratch repo before editing:

- **`--no-ff` runs no `git push` at all.** It builds the merge with
`commit-tree` + `update-ref` and syncs the worktree with `read-tree`; a
`-vv` trace shows zero `git push` invocations. The opener attributed the
whole command to `git push` with `--no-ff` as an example four lines
below. Its worktree sync is also best-effort, so the ref can move while
the worktree stays behind.
- **The Outcomes table was falsified by the upstream-swap topology.**
With local `main` diverged and behind `origin/main`, `wt step rebase
main` prints `Already up to date with origin/main` — `main` is not an
ancestor of the branch, and the result names a ref never typed.
- **"Nothing here reaches a remote" was false on a fresh clone.** With
no target argument and no cached `worktrunk.default-branch`,
`default_branch()` falls through to `git ls-remote`. The claim is now
"no commits leave the repository", which holds unconditionally.
- **The generated Arguments table contradicted the prose.** `[TARGET]
Target branch` sat two paragraphs below "the target is any commit-ish";
a blind reader nearly concluded rebase needs a branch name.
- **The table implied mutually exclusive rows.** `is_rebased_onto` is
checked first, and a branch at the target's tip satisfies both of the
first two.
- Plus: an orphan branch matched no row; "git's own output names the
ways out" is false under `advice.mergeConflict false`; "refused rather
than forced" raised the force question without closing it.

Adjacent, in files this change already touched: `wt merge`'s step 3
contradicted the new table and step 5 restated `wt step push`'s rule
with no link, so half the pipeline deferred and half repeated. Step 2
promised a backup ref unconditionally, but `create_safety_backup` runs
only when working-tree changes were staged — a clean-tree squash
rewrites commits and writes no `refs/wt-backup/` ref. That is a
data-safety claim, so it is corrected rather than left.
`docs/CLAUDE.md`'s subdoc "Use cases" cited `wt config create`, which
has no marker and no section.

## The code changes

**An annotated-tag target was never peeled.** Adding a tag example to
the rebase help is what made it worth running, and it did not work.
`is_rebased_onto` compared `git merge-base`, which peels an annotated
tag to its commit, against a bare `rev-parse`, which returns the tag
object's SHA. Those never match, so an annotated-tag target always
looked like it needed a rebase:

```
annotated   v1.2.0     → "outcome": "rebased"     (HEAD unchanged)
lightweight v1.2.0-lw  → "outcome": "up_to_date"   (same graph, same commit)
```

Fixed at the root by peeling with `^{commit}`, rather than documenting
the quirk. The test pairs the two tag types on one commit so the control
is a single factor away; it fails without the fix.

The reviewer caught that this test had gone missing, and it was right
about the consequence — without it, removing the `^{commit}` peel passes
CI. The cause was a merge resolution on this branch: `checkout --theirs`
on `tests/integration_tests/merge.rs` took main's whole file, dropping
`test_step_rebase_annotated_tag_is_peeled` along with the duplicate
squash test it was meant to drop. Restored, and re-verified in both
directions.

**`wt step push` ran out of a half-finished operation.** Mid-rebase, the
detached HEAD is a linear extension of the target, so the fast-forward
check passed and `wt step push main` printed `✓ Pushed to main (1
commit)` — moving the target branch onto a half-replayed history while
the worktree kept its conflict markers and the rebase stayed open. It
now runs the `ensure_no_operation_in_progress` gate `wt step rebase` and
`wt merge` have used since 84cbb0489.

#3579 landed while this was open and closed the same class for the
staging commands, with a better predicate for them: `git add -A`
collapses an unmerged path's stages, so the index is what knows, and an
index read also catches a conflicted `git stash pop` that writes no
state file. `wt step push` was scoped out of that, correctly — it stages
nothing, so an index read cannot speak for it. Its hazard is HEAD
itself, which is what the operation gate answers. The merge takes main's
`squash.rs` and its `test_step_squash_refuses_mid_merge` wholesale and
drops this branch's version of both; the gate's docstring now hands the
unresolved-conflict question to `WorkingTree::ensure_no_unmerged_paths`
instead of claiming it, and the rebase help enumerates all four gated
commands.

**A target worktree registered after its directory is gone got two
different answers.** The fast-forward died inside receive-pack — `fatal:
exec 'update-index': cd to '…' failed`, `! [remote rejected] HEAD ->
main (Up-to-date check failed)` — with the ref untouched, while
`--no-ff` moved the ref with plumbing of its own and skipped the sync,
so it succeeded over the same broken registration. Both refuse now with
the branch named and `git worktree prune` as the remedy, which is what
retires the two `.exists()` checks that papered over the state
downstream. `--no-ff` succeeding here is the one behavior this takes
away; a stale registration is worth one error rather than half a
success.

## Found and not fixed here

**Ignored files in a target worktree are silently overwritten, and `git
worktree lock` does not stop it.** `git status --porcelain` omits
ignored files, so the overlap check cannot see them and the stash does
not take them. Reproduced against the FAQ's own example
(`docs/content/faq.md:180` recommends the lock for "precious ignored
data"): a locked target worktree holding `db.sqlite` had it replaced by
the branch's tracked file, exit 0, no warning. The lock scopes to
removal only. A pathspec-limited probe of the push range detects it
without enumerating large ignored trees — `git -C <target-wt> ls-files
-o --ignored --exclude-standard -z -- <push_files>` names the colliding
file and stays silent about a 200-file ignored `target/` — but whether a
collision should refuse is a policy call, so it is left out.

## Verification

`wt hook pre-merge --yes` on the merged tree → exit 0, 4554 tests, no
pending snapshots. `zola build` clean, both anchors resolving and
merge.md's cross-page link landing. Every factual claim checked by
running `wt` against a purpose-built repo, and each of the three code
fixes has a test that fails without it.

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 19:16:20 -07:00
Maximilian Roos b6b74574a8 fix(merge): refuse unresolved conflicts in merge's own name (#3587)
Follow-up to #3579, which added the index gate that keeps conflict
markers out of a commit. The gate works on `wt merge`; it just doesn't
refuse in merge's name.

```console
$ git stash pop
CONFLICT (content): Merge conflict in f
$ wt merge --yes
✗ Cannot squash: 1 path with unresolved conflicts
  f
$ wt merge --yes --no-squash
✗ Cannot commit: 1 path with unresolved conflicts
  f
```

`wt merge` stages through two different steps, so it borrowed whichever
noun it happened to delegate to. Its sibling gate on the same command
says `Cannot merge`, so one command named itself three ways depending on
flags.

Threading a caller-supplied noun into `handle_squash` and
`CommitOptions` would fix the wording, and it's the wrong shape: `wt
merge` already runs `ensure_no_operation_in_progress("merge")` at its
entry, in its own name. Each command gates for itself; the index gate is
the one that hadn't been hoisted yet. So it moves up next to its
sibling:

```rust
repo.ensure_no_operation_in_progress("merge")?;
current_wt.ensure_no_unmerged_paths("merge")?;
```

The inner guards stay where they are — `wt step commit` and `wt step
squash` are entry points too, and they keep answering in their own
names.

Refusing at the entry also puts it ahead of the approval prompts and the
hook plan, rather than partway through the flow.

`--no-commit` changes which error it hits. It used to reach the
dirty-tree check first:

```console
$ wt merge --yes --no-commit
✗ Cannot merge with --no-commit: feature has uncommitted changes
  UU f
↳ Commit or stash changes first
```

Committing is the one thing that must not happen here, and stashing a
conflicted index isn't something git will do either.

## Verification

Every entry point, against a real repo stopped on a conflicted `git
stash pop`, with HEAD unmoved in each:

| | before | after |
|---|---|---|
| `wt merge --yes` | `Cannot squash` | `Cannot merge` |
| `wt merge --yes --no-squash` | `Cannot commit` | `Cannot merge` |
| `wt merge --yes --no-commit` | `Cannot merge with --no-commit: …
uncommitted changes` | `Cannot merge` |
| `wt step squash --yes` | `Cannot squash` | unchanged |
| `wt step commit --yes` | `Cannot commit` | unchanged |

`test_merge_refuses_unmerged_paths` pins the first row. It's driven from
the conflicted-stash-pop helper, which writes no state file, so it can
only pass through the index read — and it asserts HEAD is unmoved, not
just the message.

Full gate green (4548/4548, lints, fmt, doc sync) on the rebased head,
plus `cargo clippy --all-targets --features shell-integration-tests`.

> _This was written by Claude Code on behalf of Maximilian_

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 18:41:35 -07:00
Maximilian Roos f5d99d4fe5 fix(step): refuse to commit unresolved conflicts; one gutter symbol for every operation (#3579)
Two follow-ups from #3558, which stopped `wt merge` and `wt step rebase`
from running out of a half-finished operation. Probing the sibling
commands for the same root cause found one that was worse, and the
symbol work is what that PR's detection change made possible.

## `wt step squash` committed conflict markers

Mid-merge, invoked directly, it didn't refuse. It generated a commit
message for the unresolved markers and committed them:

```console
$ git merge side
CONFLICT (content): Merge conflict in f
$ wt step squash --yes
◎ Generating commit message and committing changes... (1 file, +6, no squashing needed)
  Merge branch 'side'

  This resolves a merge conflict between main and side branches.
✓ Committed changes @ e6262c6
$ git show HEAD:f
<<<<<<< HEAD
main
||||||| 56e6eb3
base
=======
side
>>>>>>> side
```

`MERGE_HEAD` is gone and the working tree is clean, so the broken merge
reads as complete. `wt merge` was never exposed — its own gate stops it
before it reaches squash — so this was the direct-invocation path only.

## The index is what knows

The obvious fix is the gate #3558 added, and it is not sufficient. The
hazard is narrower than "an operation is open", and also wider.

`git add -A` collapses an unmerged path's three index stages into one
entry. That resolves the conflict as far as the index is concerned,
while `<<<<<<<` is still on disk — and it takes with it git's own
refusal to commit an unmerged index, which is what would otherwise have
stopped this. So the exposure is exactly the commands that stage on the
user's behalf, and it does not need an operation to be open at all:

```console
$ git stash pop
CONFLICT (content): Merge conflict in f
$ ls .git/MERGE_HEAD
ls: .git/MERGE_HEAD: No such file or directory
$ wt step commit --yes
✓ Committed changes @ 1f5387c        # markers and all
```

A conflicted `git stash pop` leaves unmerged paths with no state file
written, so no reading of `.git/` can see it.
`WorkingTree::ensure_no_unmerged_paths` reads the index instead — `git
diff --diff-filter=U`, the same question `git commit` asks — and both
staging paths call it before staging:

```console
$ wt step commit
✗ Cannot commit: 1 path with unresolved conflicts
   ┃ f
```

The paths are worth carrying where the operation refusal carried
nothing: they are the one thing the user needs and git isn't being
asked.

`wt step squash` additionally takes the operation gate, ahead of its
branch check, so mid-rebase it names the open rebase rather than blaming
the detached HEAD and offering `git switch <branch>` — the one command
that throws the rebase away, which is the same wrong remedy #3558
removed from `wt merge`.

Both guards run before the pre-commit hooks and the LLM call. Neither is
worth running for a commit that can't happen, and a refused commit runs
no project commands — checked with a `pre-commit = "touch HOOK_RAN"`
project config that never fires.

`wt step commit --dry-run` is deliberately not gated. It mutates
nothing, and guarding it displaced
`test_step_commit_dry_run_propagates_git_add_failure`, which pins a
distinct error path; a tested behavior is worth more than cosmetic
parity.

## One symbol for every operation

The Status column had `⤴` for rebase and `⤵` for merge, and nothing for
the other three states git can leave open. A worktree stopped
mid-cherry-pick, mid-revert, or mid-bisect rendered as idle — including
the case #3558 closed, a multi-commit cherry-pick whose stop was
resolved with `git commit`, which leaves a clean tree, no
`CHERRY_PICK_HEAD`, and only the queued sequencer to say anything is
wrong.

Three more glyphs was the obvious fix and the wrong one. What the reader
does about any of the five is identical: run `git status`, then finish
or abort it. Splitting the column across a glyph per operation asked
them to distinguish states that lead to the same next step, and the
split is what left the other three invisible. So they collapse to one:

```console
$ wt list
  Branch  Status  …  Message
@ main       ↻^   …  resolved by hand
```

`git status` names which operation it is, in git's own words — the same
division of labor as the refusal message. `--format=json` keeps the
identity the symbol drops, so a consumer that needs it still has it:
`operation_state` gains `cherry_pick`, `revert`, and `bisect` alongside
`rebase` and `merge`.

That retires `ActiveGitOperation`, which existed only to re-encode
`InProgressOperation` down to the two states the gutter knew.
`WorktreeData.git_operation` is now
`Option<Option<InProgressOperation>>` — outer `None` is "not loaded" —
matching its neighbour `has_working_tree_conflicts`. `GitOperationTask`
also stops swallowing errors: it reports a failed probe through the same
`ctx.error` channel every other task uses, rather than reporting "no
operation" for a probe that didn't answer.

## Verification

Three integration tests, each asserting HEAD is unmoved rather than only
matching the message:

- `test_step_squash_refuses_mid_merge` — the case that committed
markers.
- `test_step_commit_refuses_unmerged_paths` — driven from a conflicted
`git stash pop`, so it can only pass through the index read; the
operation check cannot see that state.
- `test_list_shows_symbol_for_bisect` — bisect because it is the only
operation that leaves HEAD on the branch and the tree clean, so nothing
else in the Status cell stands in for it. Snapshots pin both the symbol
and `"operation_state": "bisect"`.

Confirmed by hand against real repos: mid-merge, mid-rebase, and the
conflicted-stash-pop state all refuse; a mid-merge commit whose
conflicts *are* resolved still succeeds, as does a clean squash; a
queued cherry-pick and a stopped revert both render `↻` and name
themselves in JSON.

Full gate green (4500/4500 tests, lints, fmt, doc sync), plus `cargo
test --features shell-integration-tests` (2148/2148) and `cargo clippy
--all-targets --features shell-integration-tests`, which the gate
doesn't compile.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 16:33:49 -07:00
Maximilian Roos 11a1498fa1 docs(list): render wt list statusline's help on the docs site (#3568)
`wt list statusline`'s `after_long_help` documents the three output
formats, the Claude Code stdin JSON contract, and the pace segment, but
`wt list`'s help had no `<!-- subdoc: statusline -->` placeholder, so
none of it reached `docs/content/list.md` or the skill reference. It was
terminal-only.

Adding the placeholder pulls it in as a `wt list statusline` section
under `# Subcommands`, matching how `wt step` and `wt config` expose
theirs.

## What reading it on the web surfaced

The help text needed edits once it rendered as a page rather than a
block below an Options list. A context-blind agent was given the two
rendered pages and asked to answer setup questions from them alone;
three of its snags were real.

**The format lists were stale and used private names.** They read
`branch status ±working commits upstream ci url`, while the Columns
table higher up the same page calls those `HEAD±`, `main↕`, `Remote⇅`.
They also omitted `main…±`, which `format_statusline_segments` has
emitted since that column became a default. The lists now use the column
names and include it, and `claude-code` is stated as a delta on `table`
rather than repeating it.

**The formats read as a fixed layout.** The example line on the Claude
Code page has nine segments against an eleven-name format string, with
no explanation. Three rules were in the code and in no doc: empty cells
are omitted, `claude-code` drops `branch` when `dir` already ends in
`.<branch>` (`filter_redundant_branch`), and an overlong line drops
whole cells worst-priority-first (`fit_to_width`). All three are now
stated.

**The latency caveat lived only on the Claude Code page.** A reader of
the command's own docs had no way to learn it reaches the network. It
moves to the reference. That exposed a contradiction with the
definition, "Single-line status for shell prompts", against a caveat
saying it is too slow for a synchronous prompt — so the definition
becomes "Single-line status for the current worktree". This is the one
user-visible string change here; it lands in `wt list --help` and the
three help snapshots.

## Deduplication with the Claude Code page

The pace paragraph and the OSC 8 paragraph were near-verbatim on both
pages, and would have rendered twice on the site. `claude-code.md` keeps
what is Claude Code-side (install, demo, the example line, and a plain
note that the links degrade to unclickable text where the terminal lacks
support) and links to the reference for the rest.

## Follow-ups folded in

The module docstring at `src/commands/statusline.rs:4` carried `±working
commits upstream`, the last occurrence in the tree of the labels this
branch retired, and opened "Statusline output for shell prompts" — the
framing the definition dropped. Both now match the help text.

## Not done

An audit of every `after_long_help` in `src/cli/` found 46 that never
reach a docs page. Most are editorial calls rather than oversights: `wt
config create` alone would embed ~450 lines of example TOML into a page
that already covers that ground by hand. The one that looks like a plain
oversight is `wt step rebase` / `wt step push`, the only two of twelve
step operations without a marker, and the only two the `## Operations`
list leaves unlinked. Covering them needs their openers rewritten first,
since both currently restate their definition. Left for a follow-up.

Verified with `wt hook pre-merge --yes` (4499 tests) and a `zola build`,
which checks internal anchors.

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 07:45:25 -07:00
Maximilian Roos 84cbb0489e fix(step): refuse to rebase or merge from a worktree mid-operation (#3558)
## The bug

With a rebase in progress (a conflicted stop, or a killed `git`), `wt
step rebase main` reported success over a conflicted tree:

```console
$ git rebase main          # stops on a conflict; HEAD detached mid-replay
$ wt step rebase main
○ Already up to date with main
$ echo $?
0
```

`handle_rebase` asked `is_rebased_onto` first. Mid-rebase, HEAD sits on
a linear extension of the target, so that returns true and the function
returns `UpToDate` before anything consults `worktree_state`.

`wt merge` from the same state failed differently but no better:

```console
✗ Cannot merge: not on a branch (detached HEAD)
↳ To switch to a branch, run git switch <branch>
```

The hint names the one command that throws the rebase away instead of
finishing it.

## The fix

Both commit-replaying commands now check for an open operation before
doing anything else. `wt merge` runs the check ahead of its branch
check, so the open operation is what gets named rather than the detached
HEAD it causes.

```console
$ wt step rebase main
✗ Cannot rebase: a git operation is already in progress
```

The gate covers every state detection reports, not just rebase. Measured
on the others beforehand: a rebase started mid-bisect ran and stacked a
second operation on the first, and one started mid-merge or
mid-cherry-pick died inside git's autostash (`Cannot save the current
index state`, `f: needs merge`). None told the user what to do next.

## Why it prescribes no remedy

An earlier draft of this branch carried a per-operation table of
`--continue`/`--abort` hints. It was wrong on first contact with the
long tail: `git am` borrows the `rebase-apply` directory, so an open am
session was reported as a rebase and pointed at `git rebase --continue`,
which git refuses outright:

```console
$ git rebase --continue
fatal: It looks like 'git am' is in progress. Cannot rebase.
```

Adding an `Am` arm would have treated the enumeration as the fixable
part. `git status` already answers this better than the table did, in
git's own words and translations, for states worktrunk has no variant
for:

```console
$ git status
interactive rebase in progress; onto b162044
You are currently rebasing branch 'feat' on 'b162044'.
  (fix conflicts and then run "git rebase --continue")
  (use "git rebase --skip" to skip this patch)
  (use "git rebase --abort" to check out the original branch)
```

It names the operation, the branch, the progress, and every way out
including `--skip`, which the table never offered. So the table is gone
rather than extended, and the refusal is a single line: it says what is
blocked and why, and leaves the rest to the command every git user
reaches for next.

Detection still returns a structured `InProgressOperation` rather than a
display string, but now purely as detection: the only caller that reads
a variant is the one classifying a rebase worktrunk itself just started.

The neighbouring `RebaseConflict` error took the same treatment. It
forwards git's stderr in the gutter when it has it, and otherwise
substituted two hint lines of its own:

```console
↳ To continue after resolving conflicts, run git rebase --continue
↳ To abort, run git rebase --abort
```

That is the operation table again in miniature — one operation's
remedies, minus `--skip` — and the one path that reaches it is the
safety check for a rebase git *reported as finished*, where worktrunk
knows least about what is actually open. It now prints its title, plus
git's own words when git said any.

## One detector

Two places probed the same files for the same answer:
`Repository::worktree_state` for the gate, and
`WorkingTree::is_rebasing`/`is_merging` for the `wt list` gutter
symbols. They are now one method, `WorkingTree::operation_in_progress`,
which `Repository::operation_in_progress` delegates to — the rename also
retires a name that collided with `wt list`'s unrelated `WorktreeState`.

Detection now runs in the order `git status` consults the state files,
which is a change from the old `wt list` path: it checked rebase before
merge. Nothing can reach the difference — no rebase backend writes
`MERGE_HEAD`, checked against all three — but matching git's order is
one less thing to reason about. `wt list` maps the operations without a
gutter symbol (cherry-pick, revert, bisect) to `None`, so its output is
unchanged.

## The queued sequencer, now closed

Having one detector is what made the last gap worth closing. `git
cherry-pick A B` that stops on A writes `CHERRY_PICK_HEAD`; resolving
that stop with `git commit` instead of `git cherry-pick --continue`
removes the file and leaves B queued:

```console
$ git status
On branch main
Cherry-pick currently in progress.
  (run "git cherry-pick --continue" to continue)
$ ls .git/CHERRY_PICK_HEAD
ls: .git/CHERRY_PICK_HEAD: No such file or directory
$ cat .git/sequencer/todo
pick 1f07533 side1
pick 6b32540 side2
```

Git reads that same file to keep calling the sequence in progress, so
detection reads it too: the first instruction word, `pick` or `revert`,
exactly as git's own `sequencer_get_last_command` does.

The reason this waited for its own change is the risk of refusing a `wt
merge` the user can't un-refuse, so the lifecycle is measured rather
than assumed. The directory is gone once the sequence finishes, and gone
after `--quit` or `--abort`; a single-commit cherry-pick never creates
one; a rebase keeps its todo elsewhere, under `rebase-merge/`. An empty
or unrecognized todo reports nothing, so the failure direction is the
old behavior rather than a refusal with no way out.

## Verification

The repro above, plus the merge, am, cherry-pick, revert, and bisect
states, checked by hand against real repos: all five refuse, including
am, which is now correct precisely because nothing here claims to know
what am needs.

Three integration tests drive the real binary from a genuinely stopped
operation (`test_step_rebase_refuses_mid_rebase`,
`test_merge_refuses_mid_rebase`, `test_merge_refuses_mid_merge`).
`test_merge_refuses_mid_merge` is what holds the broadened scope: a
conflicted merge keeps HEAD on the branch, so it is the case the
detached-HEAD check waves through. Narrowing the gate back to
rebase-only turns it red, which I verified by making that edit and
re-running.

The sequencer case is driven end to end from a real stopped pick —
`test_operation_in_progress_reads_the_queued_sequencer` commits the
resolution by hand, asserts `CHERRY_PICK_HEAD` is gone, and still
expects a cherry-pick, so it can only pass through the sequencer read.
It then runs `git cherry-pick --quit` and expects the state to clear,
which is the false-refusal half. Running the same sequence against the
built binary refuses where it previously reported `○ Already up to date
with main`.

Confirmed unchanged: already-up-to-date, fast-forward, true-rebase, and
rebase-conflict output; a plain detached HEAD still gets the `git
switch` hint, which is the right remedy when no operation is open; a
sibling worktree mid-rebase does not block a clean one; `wt list`'s
gutter symbols.

Full gate green (4494/4494 tests, lints, fmt), plus `cargo clippy
--all-targets --features shell-integration-tests`, which the gate
doesn't compile.

## Merged with main

`main` gained #3539, which extracted the failed-rebase classification
into `classify_rebase_failure`, and #3549, which adds `span_upstream` —
both inside `handle_rebase`. The conflict was semantic as well as
textual: #3539's `is_rebasing` check still read the old string API
(`worktree_state()?.is_some_and(|s| s.starts_with("REBASING"))`), which
this branch removes, so resolving the markers alone left a type error.
The resolution keeps #3539's classification and reads the structured
enum instead:

```rust
let state = repo.operation_in_progress()?;
let is_rebasing = matches!(state, Some(InProgressOperation::Rebase));
return Err(classify_rebase_failure(e, is_rebasing, &integration_target));
```

All five of #3539's classification tests pass alongside the gate.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 07:39:28 -07:00
Maximilian Roos 9ecd7945e0 fix(merge): measure squash/rebase against the target's upstream, not a stale local ref (#3549)
Fixes #3519.

## The bug

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

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

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

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

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

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

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

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

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

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

## Tests

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

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

> _This was written by Claude Code on behalf of Maximilian_

---------

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

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

## Why it was off

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

## Verification

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

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

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

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

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

## Shape of the change

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

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

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

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

## Safety of the truncation interaction

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

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

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

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

## Docs

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

## One judgement call worth flagging

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

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:20:01 -07:00
Maximilian Roos 6a85da8f46 refactor(eval): render -v variables through the shared formatter (#3536)
`wt step eval -v` was the last of the four `-v` template-variable
listings to hand-roll its own formatting. The hook (foreground +
background) and alias sites all render through `format_hook_variables` /
`format_alias_variables`; eval instead sorted keys alphabetically,
padded, and joined them by hand. #3495 labelled all four block *headers*
with their owner, which made eval's differently-formatted *body* stand
out.

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

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

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

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

> _This was written by Claude Code on behalf of max_

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

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

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

> _This was written by Claude Code on behalf of max_

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

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

## What was there

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

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

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

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

## The change

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

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

**Sites converted:**

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

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

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

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

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

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

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

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

## Testing

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

> _This was written by Claude Code on behalf of max_

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

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

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

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

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

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

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

Thanks @indexzero for reporting in #3454.

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:45:22 -07:00
Maximilian Roos a6972e4be0 fix(shell): detect the current shell from the process tree, not $SHELL (#3455)
`wt switch` could tell a fish user "shell requires restart" because
`$SHELL` (the login shell, say zsh) had the integration line while the
shell actually in use didn't. `current_shell()` keyed everything on
`$SHELL`, which names the login shell rather than the shell wt was
invoked from.

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

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

---------

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

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

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

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

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

Before/after on a repo with agent worktrees:

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

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

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

Thanks to @dmsmidt for the report in #3389.

> _This was written by Claude Code on behalf of max_

---------

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

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

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

The same output now reads:

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

## Changes

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

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

## Testing

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:17:30 -07:00
Worktrunk Bot c532d2dabd docs(llm-commits): bump Codex commit-generation model to gpt-5.6-luna (#3430) 2026-07-12 08:15:26 -07:00
Chris Chen 0b42d42670 feat(step promote): add --format text|json flag (parity with rebase/push/etc.) (#3424) 2026-07-12 02:01:05 -07:00
Worktrunk Bot bd504f0998 feat(switch): run --execute against the picked worktree (#3394) 2026-07-10 04:30:14 -07:00
Maximilian Roos dac0f80608 feat(config): wt config update pins [list] json-schema while unset (#3411)
Wires the `[list] json-schema` nag into the standard config-update
machinery, so the warning comes with the standard one-command fix.

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

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

The seven design items:

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

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

## What a reviewer needs

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

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

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

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

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

## Testing

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:35:28 -07:00
Worktrunk Bot f593438e7c feat(remove): add experimental --reap to kill worktree processes (#3396)
Implements the design agreed in #3365: an opt-in, experimental flag on
`wt remove` that reaps processes left running in the worktree.

## The name

`--reap` — it's the term the whole thread and the issue title already
use, it's concise (matching worktrunk's flag style), and the user-facing
messages read naturally (`◎ Reaping 2 processes under feature
worktree`).

## What it does

```console
$ wt remove --reap feature
◎ Reaping 2 processes under feature worktree
   ┃ 51234 node
   ┃ 51240 esbuild
✓ Reaped 2 processes
◎ Removing feature worktree & branch in background (same commit as main, _)
```

Processes are discovered by working directory (`lsof -d cwd`): any
process whose cwd is at or under the worktree path. Termination reuses
the existing `SIGTERM`→wait→`SIGKILL` escalation (`escalate_terminate`,
shared with the fsmonitor sweep).

## Data-safety posture

Killing a process the user didn't mean to kill — a terminal editor with
unsaved buffers — is exactly the silent loss-of-work the project refuses
without consent, so two guards keep `--reap` conservative:

- **Controlling-terminal exclusion.** A process holding a controlling
terminal (an interactive shell, or `vim`/`nvim`/`emacs -nw`) is never
reaped (`ps -o tty=`). Only detached processes — the dev servers and
watchers this issue is about — remain candidates. This also spares the
shell `wt remove` was run from.
- **Self-exclusion.** The current `wt` process is never a candidate.

The flag itself is the explicit opt-in; the list is printed before
signalling for transparency.

## Scope / limitations (matching the #3365 discussion)

- **Under-inclusive by design.** cwd discovery misses a daemon that
forked and `chdir`'d away, or one that reparented to `init` — they no
longer report a cwd under the path. Those are what [`wt step
tether`](https://worktrunk.dev/step/#wt-step-tether) is built to reap
(whole process group). `--reap` and `tether` cover different gaps and
are complementary, not substitutes — the docs say so.
- **Ordering.** Reaping runs before the worktree directory is
staged/renamed (cwd matching needs the directory in place), so it's
independent of foreground/background removal, trash-vs-delete, and
`--force`.
- **Unix only.** Windows has no cheap per-process cwd; `--reap` is
rejected there with a clear error.

## Tests

- Pure parsers for `lsof`/`ps` output (`parse_lsof_cwd`,
`parse_ps_tty`).
- End-to-end against the real `lsof`/`ps`: spawns a child with a cwd
under a tempdir, asserts `processes_under` discovers it, asserts the
controlling-terminal guard keeps-or-drops it in agreement with the
child's *actual* TTY state (so the test is host-independent — CI has no
TTY, a dev box does), then reaps it and confirms `SIGTERM`.
- CLI snapshot (`test_remove_reap_no_processes`) covering the
no-candidates path, deterministic whether or not `lsof` is installed on
the runner.

Help text, `docs/content/remove.md`, and the skill reference mirror are
regenerated and in sync.

One thing worth a maintainer's eye: I chose to print-then-signal rather
than add an interactive confirm/`--dry-run` in this first cut — the
opt-in flag + TTY exclusion + printed list felt like enough for an
experimental flag, and a confirm step is easy to layer on if you'd
prefer it.

Closes #3365.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-09 15:13:46 -07:00
Worktrunk Bot a38481bf31 feat(diagnostic): surface pager/terminal env vars in the -vv report (#3328) 2026-07-08 01:48:26 -07:00
Worktrunk Bot 512a94de2f feat(plugin): ship Codex-native activity hooks (#3364) 2026-07-06 23:05:15 -07:00
Maximilian Roos 9edf0aa6b3 feat(approvals): add wt config approvals list and clear --stale (#3380)
## What

Two additions to `wt config approvals`:

- **`list`** — shows every command the project config declares (hooks in
lifecycle order, aliases, commit-message guidance) grouped into
**APPROVED** and **UNAPPROVED** sections, using `wt hook show`'s symbol
vocabulary (`○` approved, `❯` requires approval). Approvals recorded for
commands no longer in the config (edited or removed since approval) are
listed in a separate warning block. Works without a project config
(recorded approvals still list, all stale). Output pages like `wt hook
show`.
- **`clear --stale`** — removes only those left-behind approvals,
echoing each removed command; valid approvals survive. Staleness is
recomputed under the approvals-file lock (`Approvals::revoke_stale`), so
an approval recorded concurrently by another process is never removed.
Conflicts with `--global` (staleness is defined by the current repo's
config). A missing project config is an error, matching `add` —
approvals are keyed repo-wide while the config resolves per-worktree, so
a branch that merely lacks the file must not classify everything as
stale and wipe the repo's approvals; intentional wipe-everything remains
plain `clear`.

The `list` stale-block hint points at `clear --stale` instead of the
all-clearing `clear`.

## Example

```
APPROVED
○ pre-merge:
 ┃ cargo test

UNAPPROVED
❯ post-start dev:
 ┃ npm run dev

▲ Approved commands no longer in project config:
 ┃ some removed command
↳ To clear stale approvals, run wt config approvals clear --stale
```

## Notes

- The approval-prompt rendering and `approvals add` share the new
`ApprovableCommand::{label,format_template}` helpers and
`collect_approvable_commands`, so the prompt, `add`, and `list` render
commands identically.
- `list` is strictly read-only over approval state; nothing here runs a
project command.
- Staleness matching normalizes deprecated template variables on both
sides, same as `is_command_approved`.
- Also documents the ✓-vs-○ rule (completed action vs state) in the
`writing-user-outputs` skill.

## Testing

Unit tests for `stale_approvals`/`revoke_stale` (normalization both
directions, no-op, project-entry removal); integration snapshots for
`list` (mixed, all-approved, stale-only, no-config) and `clear --stale`
(removal + follow-up list, nothing-stale, `--global` conflict, and
no-config erroring with the approvals file proven intact). Full
pre-merge gate green.

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:19:58 -07:00
Maximilian Roos 56e20a4a78 feat(list): add JSON schema 2 behind [list] json-schema (#3357)
Adds a second JSON schema for `wt list --format=json` and `wt list
statusline --format=json`, selected by a new `[list] json-schema` config
key. Schema 2 is an envelope (`schema`, `repo.default_branch`,
`repo.forge`, `collected`) over items carrying independent facts; schema
1 (the current bare array) is byte-identical to today and remains the
default. Unset emits schema 1 plus a once-per-process stderr nag naming
both settings; `= 1` pins silently; an invalid value warns and degrades
like any other config problem. The nag is suppressed on the statusline
surface, which would otherwise corrupt prompts.

**The semantic core is the absence rule**: absent = nothing to report
(not applicable, not requested per `collected`, or determined-empty),
null = requested but undetermined (probe pending, timed out, fetch
failed). Three mechanisms keep it honest — `integration` derives from
the same committed-content signals `wt remove` trusts
(`check_integration`) rather than the cleanliness-gated display
collapse; skip-seeded conservative defaults are recorded on
`ListItem.seeded` and serialize as null instead of masquerading as
determined facts (invariant-pinned: no seed can fabricate a positive
integration match); and orphan sentinel counts are guarded so they can't
read as a same-commit match.

**For reviewers, in reading order**: `src/commands/list/json_v2.rs` (the
serializer and its `Tri` tri-state), `src/commands/list/mod.rs`
(`resolve_json_schema` + wiring), `src/commands/statusline.rs`
(`run_json`), and small model/collect extensions (`SeededFacts`,
`Collected`, `UpstreamStatus.upstream_short`, task-plan union so a
listed `ci` column forces the fetch for JSON like the table).

**Design doc**: the full rationale, field-by-field mapping, and
migration plan were reviewed as `design/list-json-v2.md`, which rode
this branch as its first commit. Design docs are review-only by repo
convention, so a final commit removes it from the net diff; it remains
readable at e1c9b72e0.

**Testing**: 28 serializer unit tests pin the absence rule's edge cases
(seeded families, orphan sentinels, partial signals, pr/checks splits,
the schema generation itself); two producer-driven tests sweep every
`TaskKind` seed arm; integration tests cover all four schema-selection
behaviors on both surfaces, the statusline outside-a-worktree paths, and
a full envelope snapshot; the five existing schema-1 snapshots pin the
nag and prove schema-1 stdout unchanged. Full pre-merge gate green
(4,344 tests) and `--features shell-integration-tests` clippy clean. Not
covered: live-forge behavior of the v2 `pr`/`checks` split (the
forge-mock integration paths exercise schema 1; the split is unit-tested
from `PrStatus`).

Deferred to follow-ups, recorded in the design doc's Ripples section:
the cross-forge fetcher untangling so `pr.mergeable` learns the positive
case, and the schemars schema export + docs sync test (lands before the
default flips).

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:52:14 -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 4388defff5 feat(list): add git.branch.* template namespace for custom columns (#3319)
Adds a `{{ git.branch.* }}` template namespace for `wt list` custom
columns, parallel to `{{ vars.* }}`. It surfaces a branch's own git
config under `branch.<name>.*` — both convention keys you set yourself
(`branch.<name>.jira`) and the git-native `branch.<name>.description` —
without re-storing the values through `wt config state vars set`. This
is the gap left open in #3258: `vars.*` only reads worktrunk's
`worktrunk.state.<branch>.vars.*` namespace, so keys a user already
keeps in git config were invisible to the list.

## Before / after

With `branch.feature.jira` and `branch.feature.description` already in
`.git/config`:

```toml
[list.custom-columns.Jira]
template = "{{ git.branch.jira }}"
[list.custom-columns.Summary]
template = "{{ git.branch.description | lines | first }}"
```

```
Branch     …  Jira          Summary
feature    …  HWINFCI-2810  Add telemetry instrumentation
main       …                                                 ← no branch.main.*, empty cells
```

Previously the only way to populate these columns was to re-enter the
data via `wt config state vars set`; now the branch's own config is read
directly.

## Design

- A new `git` top-level namespace (rather than a flat `branch_config`)
so it can grow other git-derived per-branch fields later
(`git.upstream`, `git.remote`, …) without claiming a new top-level name
each time. Today it holds `git.branch.*`.
- `git.branch.<key>` maps 1:1 to `git config branch.<name>.<key>`. Note
git lowercases config variable names, so `branch.<name>.nvciShelf` reads
as `{{ git.branch.nvcishelf }}`; the git-native `description` is
multi-line, so `| lines | first` gives the summary line.
- Data comes from the existing in-memory bulk config snapshot — one
read, zero subprocesses per cell, on the same skeleton-first path as
`vars`. The reader shares a `subsection_map_from_snapshot(parse)` helper
with the existing `all_vars_from_snapshot`.
- Parsing splits `branch.<name>.<key>` with `rsplit_once('.')`, which is
correct because git variable names can't contain dots: dotted/slashed
branch names (`feature.foo`, `feature/bar`) keep their full subsection,
and git's section-level keys (`branch.sort`, `branch.autoSetupMerge`)
flatten to two segments and are skipped.
- Scoped to list custom columns only — no leakage into
hook/alias/pipeline template contexts.

## Testing

Unit tests for the parser/reader (dotted + slashed branch names,
variable-name lowercasing, `branch.sort` skip) and for rendering
(including `description | lines | first`); a JSON integration test
exercises the full `wt list` path end-to-end. Verified manually against
a scratch repo. Full pre-merge gate green (4297 tests, clippy, fmt,
rustdoc, docs-in-sync).

Closes #3258. Thanks to @cazador481 for the request.

> _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 11:19:08 -07:00
Maximilian Roos fec7edc72c feat(list): force a listed column on past the --full preset gate (#3295)
## Problem

A `[list] columns` entry could narrow the table but never *force a
column on* past its gate. Listing `ci` without `--full` still hid the CI
column; the gate won. But `--full` is a column-selection preset — it
bundles CI/summaries into the default table — not a feature-approval
switch, so "I listed `ci`" being silently overridden by "you didn't pass
`--full`" is surprising.

## Solution

A listed column now overrides the **preset** gates while still
respecting the **data-source** gates. The distinction is a new
`ColumnSource` (`Default` | `Listed`) threaded through the task planner:

- **Preset gates** — `--full` (`show_full`), `[list] summary`
(`summary_enabled`) — bundle columns into the *default* table. A
`Listed` column overrides them: listing `ci` shows it without `--full`;
listing `summary` shows it whenever an LLM command exists.
- **Data-source gates** — `[commit.generation]` (`has_llm_command`),
`[list] url` (`has_url_template`) — are hard prerequisites. They apply
even to a `Listed` column: a listed `summary` with no LLM command, or
`url` with no template, stays hidden, because listing can't conjure data
that isn't configured.

`Default`-source behavior (the default table, the picker, and `--format
json`, which all plan over `all_columns`) is byte-for-byte unchanged —
`column_renders(kind, Default, gates)` reduces to the old function.

### Before / after

`columns = ["branch", "ci"]`, no `--full`:

```
before:  Branch              # ci dropped — gated by --full
after:   Branch   CI         # listed → forced on
```

### Picker consistency

The picker plans tasks from `all_columns` (it needs every column's data
for its preview tabs) but renders the `[list] columns` selection. So it
now plans the **union** of its preview set and the selection's forced-on
columns, keeping its table identical to `wt list`'s. This matters for
one case: a listed `summary` with an LLM command configured but `[list]
summary` off — `wt list` shows it, and without the union the picker
would have hidden it.

## Testing

- **Unit** (`test_required_tasks_for_render`): both sources × every gate
combination — listed-`ci`-without-`--full`, listed-`summary` overriding
the presets, listed-`summary` still needing an LLM command, listed-`url`
still needing a template, and `Default`-source parity.
- **Integration**
(`test_list_config_listed_column_overrides_full_gate`): end-to-end —
`columns = ["branch", "ci"]` renders the `CI` header with no `--full`,
where the default set hides it. Asserts on column enrolment (the
rendered header), independent of whether `gh` is on `PATH`.
- **Layout**: the filter test was repurposed
(`test_layout_renders_column_iff_task_planned`) to test "render iff task
planned" in isolation, since the planner no longer produces the old
"listed but unplanned `ci`" pairing.
- Full `pre-merge` gate (all suites, fmt, clippy, docs-sync, PTY picker
snapshots) green after merging `main`. The picker's 44 PTY tests pass
with no snapshot churn.

## Docs

The `[list] columns` help prose was rewritten ("Listing a column forces
it on, space permitting…") in the `src/cli/mod.rs` source and
regenerated across all four render contexts (terminal `--help`,
`config.example.toml`, `docs/content/config.md`, skills reference). The
`CLAUDE.md` network-access inventory also now notes that a listed `ci`
column reaches the wire without `--full`.

> _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 16:04:38 -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 14c79c161b fix(picker): show the resolved config path in disabled-summary hints (#3290)
When LLM summaries are disabled, the `wt switch` picker's Summary tab is
seeded with one of two hints — "Summaries not configured" when there's
no `[commit.generation]` command, or "Summaries off" when `[list]
summary = false`. Both previously told the user to edit a hardcoded
`~/.config/worktrunk/config.toml`, which is wrong whenever wt loads
config from elsewhere: a `--config` flag, the `WORKTRUNK_CONFIG_PATH`
env var, or a non-default `$XDG_CONFIG_HOME`. A user following the hint
would edit a file wt never reads.

Both hints now show the path wt actually loads from, resolved once at
picker startup via `worktrunk::config::config_path()` (priority:
`--config` → `WORKTRUNK_CONFIG_PATH` → platform default) and rendered
through `format_path_for_display` so it shows `~/…`. When no location
can be determined, it falls back to the canonical literal. This mirrors
the resolved-path idiom already in `src/commands/worktree/resolve.rs`.

The hints are also reshaped into the gutter house style — a bold
H4-subject first line and a fenced ` ```toml ` config block — structured
so the PTY snapshot is platform-stable. `render_summary` word-wraps
prose to the preview width, which is one column narrower under Windows'
PTY, so any prose body long enough to wrap breaks on a different word
there and a single cross-platform snapshot can't match. To avoid that,
the only prose is a short lead line that can't wrap, the resolved path
sits alone on its own line (one unbreakable token, so there's no wrap
boundary to shift), and the config block is never wrapped (code blocks
aren't). The H4 subject is never wrapped either.

## Tests

`test_switch_picker_preview_panel_summary_disabled` is added to cover
the "configured but `summary = false`" arm, which had no test before
(the existing summary test only exercises the not-configured arm) —
closing a `codecov/patch` gap. Both arms' PTY snapshots render the
resolved path; under the tests' `WORKTRUNK_CONFIG_PATH` isolation that
path is a per-run temp file, which the existing snapshot filter redacts
to a stable `[TEST_CONFIG]` placeholder, so the snapshots stay
host-independent and `test_no_host_specific_paths_in_snapshots` is
satisfied.

> _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 13:21:33 -07:00
Worktrunk Bot d5be156092 perf(list): plan background tasks from the columns being rendered (#3274)
## Problem

`[list] columns` filtered purely at the layout layer. A narrowed
selection like `columns = ["branch", "path"]` hid the unselected columns
but still ran every per-worktree git task — `git status`, working/branch
diffs, ahead/behind walks, merge-conflict probes — then threw the
results away. On the kind of repo that motivated #3133 (27 dirty
worktrees) that discarded work is the bulk of the wall-clock cost, so a
"just branch and path" view was no faster than the full table.

This was flagged in the [trace-based diagnosis on the
issue](https://github.com/max-sixty/worktrunk/issues/3133#issuecomment-4816169750):
`columns = ["branch","path"]` and the default set produced an
**identical** command list. @max-sixty
[confirmed](https://github.com/max-sixty/worktrunk/issues/3133#issuecomment-4819594461)
it's a bug and asked for the fix.

## Solution

`wt list` decides which background tasks to run in **one canonical
stage**, driven by the columns it will render. The plan flows through
the whole pipeline as a **positive set of tasks to run** — no skip-list,
no inversion, no blanket default.

`collect` computes `tasks` = the union of each rendered column's
`required_tasks()`, gated by the conditions that turn a column off
(`--full`, `[list] summary` + `[commit.generation]`, a url template).
The spawn loop fires exactly that set; the layout renders exactly the
columns it feeds. The rendered set is the `[list] columns` selection for
the table; the picker and `--format json` plan from every column,
because their consumers — the picker's preview tabs, JSON's every-field
contract — need the full data set, not just what renders.

This started as additive pruning layered on the old `skip_tasks`
denylist; review (thanks @max-sixty) pushed it to the canonical,
positive form:

- **One column→task map.** `ColumnSpec::requires_task` is deleted;
`ColumnKind::required_tasks()` is the single source, driving both the
spawn plan and the layout visibility filter (`renders_given_run` — a
column renders iff one of its tasks is in the plan). The two maps can no
longer drift, so the reconciliation test is gone; the `cover_every_task`
drift guard stays and gains teeth (an unconsumed task would never run,
not merely be computed and discarded).
- **A positive run set, end to end.** `CollectOptions` carries `tasks`
(the run set), not a skip set — `collect` threads the plan straight into
the spawn loops, the layout, and `max_pr_number` with no complement
step.
- **No blanket default.** `CollectOptions::for_columns(columns, gates)`
derives the plan; nothing hand-writes a task set. The statusline
declares what it renders (the full column set under full gates, no LLM
summary) instead of leaning on "default everything". The picker rides
`show_full` on `ShowConfig::Resolved`.
- **One mechanism for the summary.** The per-item `SummaryGenerate &&
llm.is_none()` spawn guard is dropped: the column plan is the single
authority on whether the summary runs, and `SummaryGenerateTask` already
returns a clean error on a missing command.

A branch/path `ls` alias over many dirty worktrees now runs no `git
status`, diffs, or ahead/behind walks (#3133), while a column gated off
elsewhere stays off. Behaviour is otherwise unchanged across
default/selection × full/non-full × table/JSON/picker/statusline — no
rendered-output snapshots move (the `help_config_*` snapshots move only
from the columns-doc rewrite).

## Testing

- Planner + filter units: `test_required_tasks_for_render` (the default
set needs every task; a branch/path or custom-column view needs none;
`Status` pulls in every status-feeding task; the gates drop
`ci`/`url`/`summary` even when those columns are explicitly selected)
and `test_renders_given_run` (the "render iff a task is planned" filter,
including `Status` surviving while any signal runs).
- `test_required_tasks_cover_every_task` drift guard retained: the union
of `required_tasks()` across all built-ins equals the full `TaskKind`
set, so no task can fall out of the now-load-bearing map.
- End-to-end: `test_list_config_columns_prune_unused_tasks` (default set
runs `git status --porcelain`; `columns = ["branch", "age"]` runs none)
and `test_list_json_ignores_columns_selection` (`--format json` emits
every field regardless of selection).
- Reviewed by independent finder passes (line-by-line +
removed-behavior, cross-file + picker/JSON equivalence, altitude +
conventions) — no findings; each confirmed the task set is preserved
bit-for-bit. Full `pre-merge` gate (all suites, fmt, clippy, docs-sync,
PTY picker snapshots) green after merging `main`.

Closes #3133

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-06-28 12:05:24 -07:00
Maximilian Roos e5cf9748ce perf(list): dedup commit→tree resolution via in-memory cache (#3288)
`wt list --full` peeled the shared default-branch tip to its tree once
per worktree/branch row inside the integration probe — N identical `git
rev-parse <sha>^{tree}` subprocesses for one value (the largest
duplicate-call line in a profiled run).

This adds a commit→tree memo to the in-memory `RepoCache` (a `DashMap`
get-or-create, mirroring `merge_base_by_sha`): the `Entry` match holds
the shard lock across check-and-insert, so parallel rows sharing the
target spawn one process, not one each. Both `would_merge_add_to_target`
and `trees_match_by_sha` now resolve commit→tree through it, so the
shared target tree resolves exactly once per run (verified against
`[wt-trace]` records).

In-memory rather than the persistent `sha_cache`: the resolution is ~1
ms, so cross-run persistence buys little, while the in-memory layer
eliminates the cold-run race the disk cache would have. The codebase
already had this pattern (`merge_base`, `diff_stats`) but the
in-memory-vs-disk decision was implicit — so this also documents it in
the `# Caching` module section, with the three exemplars.

The one snapshot change is an improvement: the `committed-trees-match`
failure now surfaces the clean `--verify` error (`Needed a single
revision`) instead of the confusing `ambiguous argument …`, matching
every other `rev-parse` call site.

> _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 11:36:05 -07:00
Maximilian Roos a0f07ac6d7 fix(list): diff branches against the default branch's upstream tip (#3280)
## Problem

`wt list`'s `main↕` (ahead/behind) and `main…±` (branch diff) columns
measured every branch against the **local** default-branch tip. In a
fork whose local `main`/`master` lags its upstream, those columns
reported garbage. Local default branches go stale routinely: you fetch
far more often than you fast-forward local `main`, while the
remote-tracking ref refreshes on every fetch.

Concretely, in a fork of `skim-rs/skim` whose local `master` sat 42
commits behind `origin/master`, every feature branch displayed as `↑44`
ahead and `+∞ / -5K`, when each was only ~2 commits past the real
upstream tip. The workaround was a manual `git merge --ff-only
upstream/master` to un-stale the local base before `wt list` made sense
again.

## Root cause

`wt list` already carried two notions of "the mainline ref":

- **Integration status** (`⊂` / `↑` / `↓` …) resolves the default
branch's upstream and compares against the *superset* of `{local
default, its upstream}` via `Repository::integration_targets()`.
- **The two stat columns** ignored that and diffed against the raw local
`default_branch`.

That inconsistency is the bug. A stale local default inflated the counts
by every commit it was missing, and the integration symbol and the
numbers next to it could disagree.

## Fix

Route the two informational stat tasks through the same upstream-aware
superset ref the integration column already uses. A new
`TaskContext::comparison_base()` returns `integration_targets.primary`
(the superset side), falling back to the raw `default_branch` only when
integration targets could not be resolved (snapshot capture failed).
`AheadBehindTask` and `BranchDiffTask` now consume it instead of
`default_branch()`.

This reuses one mechanism rather than adding a second upstream resolver,
and is correct across all four local-vs-upstream relationships:

| Relationship | base | vs. before |
|---|---|---|
| no upstream / local == upstream | local | unchanged |
| local behind upstream (stale fork) | **upstream** | **fixed** |
| upstream behind local (unpushed local merge) | local | unchanged |
| diverged | local | unchanged |

The "upstream behind local" row is why naively preferring the upstream
ref would be wrong. After a local `wt merge` that has not been pushed,
the default branch leads its upstream, and sibling branches must still
measure against the local tip so the unpushed merge commits do not leak
into their counts. Reusing `integration_targets.primary` gets this case
right for free, because its superset selection already keeps the local
ref there.

### Deliberately unchanged

- **Conflict columns** (`✗ WouldConflict`, via `MergeTreeConflictsTask`
/ `WorkingTreeConflictsTask`) keep using the local default branch. They
predict the local `wt merge`, which targets the local ref.
- **`wt merge` target** and **`wt switch --base`** (writes) keep using
the local branch. You merge into and update a local ref, and a new
branch defaults onto a writable local branch; a remote-tracking ref is
neither.

### Side effect

The `↑`/`↓`/`↕` gutter symbols derive from these counts
(`is_same_commit` in `model/item.rs`), so they now also track the
upstream tip in a lagging fork. A branch sitting at the stale local-main
tip renders `⊂` (its content is already in the real mainline) rather
than `_`. Removal safety is unchanged: the safe-to-remove signals
(`is_ancestor`, `trees_match`) were already upstream-aware.

## Performance

The snapshot's batched `%(ahead-behind)` walk is keyed on the local
default-branch name, so the common case (superset == local) keeps the
batch. Only the lagging-fork case falls back to a per-pair
`ahead_behind_by_sha`, which is SHA-cache-backed, and that case had
wrong numbers before. The diverged case stays local, so it keeps the
batch too.

## Tests

Two regression tests in `tests/integration_tests/list.rs`:

- `test_list_branch_stats_use_upstream_when_local_default_lags` covers
the fix. It fails on `main` with `ahead 5` against the expected `2`.
- `test_list_branch_stats_stay_local_when_default_ahead_of_upstream`
pins that the base stays local when the default leads its upstream.

The full `wt hook pre-merge` gate is green (4262 tests, clippy,
docs-sync, rustdoc).

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:03:24 -07:00
Maximilian Roos 02c0621b3d fix(list): rank Integrated (⊂) above WouldConflict (✗) in main-state (#3278)
A squash-merged branch whose default branch later re-edited the same
lines showed `✗` (WouldConflict) in `wt list`, while `wt step prune`
correctly classified it as `⊂` (all changes in main) and removed it. The
list and the prune verdict disagreed on the same branch, so `✗` read as
"unmerged work that conflicts" when the branch was in fact fully
integrated and safe to delete.

The conflict is real but vacuous: a 3-way re-merge collides on the lines
the default branch re-touched, yet the branch's whole diff already
matches a commit on the default branch (patch-id), so resolving the
merge just reproduces the default branch's tree — nothing added. That
patch-id check is exactly how `prune` reaches `⊂`; the list simply
ranked the downstream conflict above the integration verdict.

This reorders gate 3 of the main-state column so the integration family
(`⊂`/`_`) outranks `WouldConflict`, mirroring the existing rule that
`Orphan` outranks it — show the root-cause state, not the downstream
conflict. Integration stays fire-or-continue so the common `↑`/`↓`/`⊂`
cases still render promptly; only `✗` is held until the integration
verdict is final, so an integrated-but-stale branch never flashes `✗`
before settling to `⊂`. A genuinely un-integrated conflict still shows
`✗`.

Well covered by unit tests on the priority logic and three new gate
tests pinning the integrated-outranks-conflict, no-flash-while-loading,
and genuine-conflict cases. The list's rendered table output is
unchanged (the integrated-and-conflicting combination doesn't occur in
existing fixtures); a follow-up commit reorders the `wt list`
default-branch symbol docs and regenerates the `--help` snapshots to
match the new priority.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:11:18 -07:00
Maximilian Roos 8e2ab04c7d feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.

## What changes for the user

`alt-x` on a row now behaves by what the removal does:

- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.

Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.

## The cursor fix (last commit)

The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.

## Reviewing

The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.

## Testing

Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
Zexin Yuan 6fc6b37b6b feat(step): add require-include option + CLI flags to copy-ignored (#3196)
`wt step copy-ignored` copies all gitignored files by default. Add a
`require-include` setting that gates the copy on a `.worktreeinclude`
file existing in the source worktree — matching Claude Code desktop,
where the file is required. Without `.worktreeinclude`, the command is a
no-op that reports why (text hint + JSON `reason`); with it, only
matching files copy.

Config (additive, defaults off; `Option<bool>` merged via `.or()` so an
explicit value overrides lower-priority config in either direction):

```toml
[step.copy-ignored]
require-include = true
```

CLI flags override config per run (precedence `CLI > --config-set >
project > user > default`, mirroring `wt merge` / `wt remove`:

```
--require-include      copy nothing unless .worktreeinclude exists
--no-require-include   copy all gitignored files this run
```

The gate short-circuits before `git ls-files` discovery. The recovery
hint names `--no-require-include` (correct whether the flag or config
triggered it, since the flag overrides both). `wt step promote` is
unaffected — it exchanges all gitignored artifacts by design.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:54:24 -07:00
Maximilian Roos a725a2d786 Unify wt switch and --prs: differ only by the extra PR rows (#3252)
Full filter parity (branch, path, PR number/title/author) across worktree and --prs rows via a shared token builder; --prs dedups already-shown branches; worktree match text folds in PR data live as the forge fetch lands. Author plumbed through both forges.
2026-06-25 17:55:54 -07:00
Worktrunk Bot c2b7ab683e fix(shell): guard fish lazy-load wrapper against completion-mode recursion (#3250)
Follow-up to #3241, requested in #3240.

#3241 made the completion files **wt itself writes** resolve the binary,
so they no longer re-enter the fish lazy-load stub. But the stub
(`functions/wt.fish`) is still unguarded, leaving one narrow residual
case: a **stale third-party completion** that calls the bare `wt`
command with `COMPLETE` set — e.g. an old Homebrew
`vendor_completions.d/wt.fish` left on disk from before an upgrade —
re-enters the stub and recurses to fish's call-stack limit until the
package is rebuilt.

This adds the same `COMPLETE` short-circuit that bash and zsh already
use, so the stub delegates straight to the binary in completion mode
regardless of which completion file is active:

```fish
function wt
    if set -q COMPLETE
        command wt $argv   # bypass the stub, let the binary emit completions
        return
    end
    command wt config shell init fish | source
    ...
end
```

This makes fish immune to stale third-party completions, matching
bash/zsh.

## Changes
- `templates/fish_wrapper.fish` — add the `COMPLETE` guard to the
lazy-load stub.
- `src/shell/mod.rs` — `test_fish_wrapper_guards_completion_mode`
regression test (#3240).
- Regenerated the `configure_shell_fish_dry_run` snapshot.

Closes #3240.

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

---------

Co-authored-by: worktrunk-bot <worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:21:39 -07:00