Commit Graph

148 Commits

Author SHA1 Message Date
Worktrunk Bot 246c6bd919 fix(list): keep [list] columns out of the --format json plan (#3812)
Closes the `[list] columns` half of #3787, per the call in [this
comment](https://github.com/max-sixty/worktrunk/issues/3787#issuecomment-5273942067):
JSON always emits the same shape, and `list.columns` only affects the
actual columns.

Before, `--format json` planned `all_columns` (source `Default`)
*unioned* with the selection's forced-on columns, so the selection
reached JSON in one direction only — it couldn't narrow the emitted
fields, but a listed `ci` did force the forge fetch on without `--full`.
That made a presentation setting decide whether a machine-readable call
talks to GitHub, which is the thing the Neovim plugin in #3787 had to
pin `--config-set 'list.columns=[…]'` against. Now the JSON branch plans
`all_columns` alone; `--full` is the only switch for the gated data, and
it's the one a caller controls.

The table and the `wt switch` picker are untouched — a listed `ci` still
renders the CI column without `--full`, and the picker still unions the
selection in so its table matches `wt list`'s.

Only `ci` and `summary` are affected: every other column is ungated, so
`full_plan()` already covered them, and custom columns require no
background task.

**For the release note — this changes schema 1 too.** A caller with
`[list] columns = […, "ci"]` and no `--full` used to get the `ci` object
in schema-1 JSON and now won't; schema 1 has no `collected` envelope to
say why. The schema-1 `ci` row already documented `` `--full` only ``,
so the docs get *more* accurate, but the observable output changes for
anyone who was relying on the forcing path. Schema 2 reports the same
narrowing through `collected.ci`.

Docs updated in `after_long_help` (the `[list] columns` section plus the
schema-2 `pr`, `summary`, and `checks` rows — `summary` now names
`--full` alongside `[list] summary = true`, and `checks` names the
`--full` gate it shares with `pr`), with the generated mirrors,
`dev/config.example.toml`, and the `--help` snapshots regenerated. The
`CLAUDE.md` network inventory and the `collect` planning comment now
record the exemption too.

<details><summary>Test</summary>

`test_list_json_columns_selection_does_not_force_ci` in
`tests/integration_tests/list_config.rs` asserts schema 2's
`collected.ci` across three configs: unset (false), `columns =
["branch", "ci"]` without `--full` (false — the regression this fixes),
and the same with `--full` (true). `collected` records what the plan
requested rather than what a fetch returned, so the test needs no forge
and no `gh` on PATH. It sits next to
`test_list_json_ignores_columns_selection`, which owns the narrowing
direction, and `test_list_config_listed_column_overrides_full_gate`,
which owns the table's forcing behaviour and still passes unchanged.

Ran locally: full `cargo test --test integration` and `cargo test --lib
--bins`, plus `cargo clippy --all-targets` and `cargo fmt --check`. One
unrelated failure,
`test_copy_ignored_preserves_file_executable_permissions`, is a umask
artifact of this sandbox (expects `0644`, the runner's `umask 002`
produces `0664`); it touches no code in this diff.

The docs-row follow-up in df5c238 re-ran `cargo test --test integration
-- test_help test_docs_are_in_sync` (48 passed) and `cargo fmt --check`.

</details>

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-15 08:00:04 -07:00
Maximilian Roos 7f8ac8e5d7 feat(list): publish a JSON Schema for the schema-2 envelope (#3747)
`wt list --format=json` schema 2 now has a published, machine-readable
contract at
[worktrunk.dev/schema/list-v2.json](https://worktrunk.dev/schema/list-v2.json).

The schema was already derived — `test_schema_generates` built one,
asserted it compiled, and threw it away, with a comment saying "until
the schema export ships." This ships it: `wt list --print-schema` prints
the document (a developer entry point alongside `--help-page`,
intercepted before clap), and a new step in `test_docs_are_in_sync`
commits it to `docs/static/schema/list-v2.json`, the same
generate-and-commit pattern as `llms.txt`. It shells out rather than
calling `schema_for!` because `JsonEnvelope` lives in the bin-only
`crate::commands` tree.

Two things had to be fixed for the document to be usable.

**The contract.** `schema_for!` generates under schemars' *deserialize*
contract, which marks a `skip_serializing_if` field required — nothing
supplies it on the way in. The first document I generated therefore
required `default_branch`, `upstream`, `pr`, `checks`, `summary` and
`vars` on every item, all of which the absence rule routinely omits, so
it rejected the output it documents. Generating under `for_serialize()`
fixes it.

**The vocabularies.** Four fields — `checks.status`, `display.state`,
`default_branch.integration.reason` and `worktree.operation` — were
`&'static str`, so the schema described them as bare strings. They are
now `JsonCheckStatus`, `JsonMainState`, `JsonIntegrationReason` and
`JsonOperation`, each converted from its domain enum by an exhaustive
match, so a new `CiStatus`, `MainState`, `IntegrationReason` or
`InProgressOperation` variant is a compile error rather than a value
silently missing from the published vocabulary. **The emitted JSON is
unchanged**; the existing envelope snapshot passes untouched.

<details>
<summary>Before and after, for one item</summary>

```json
// before — rejects its own output, and loses the vocabulary
"required": ["default_branch", "upstream", "pr", "checks", "summary", "vars", "display"],
"status": { "type": "string" }

// after
"required": ["branch", "head", "display"],
"status": { "enum": ["passed", "running", "failed"] }
```

</details>

## Testing

`test_schema_accepts_envelopes` validates a battery — every `CiStatus`
over both sources, every `MainState`, a populated worktree row, an
integrated row with an upstream and a dev server, plus the absent and
null arms of the absence rule — against the same document
`--print-schema` emits.

Validating proves nothing about a type the battery never instantiates,
so the test also pins every non-`Nullable_` type in the document to a
path that must carry a non-null value. A new `Json*` type fails until
the battery reaches it, and a row that stops populating one fails too —
the check reports the type names rather than leaving the gap to a
reader. This needed a `jsonschema` dev-dependency: schemars only
generates, and derives the document from the types without ever seeing
an envelope, so nothing otherwise tied the two together.

The test was confirmed to fail on the bug it exists for. Reverting to
`for_deserialize()` makes it report `pr`, `checks`, `summary`, `vars`
and `display.columns` as wrongly required.

The dependency is dev-only: `reqwest`, `rustls` and `async-trait` stay
unselected so no HTTP stack comes along, and `cargo tree --package
worktrunk --edges normal -i jsonschema` finds no path to it.

One direction it deliberately does not cover: a *loosening*. If a field
reverted to `&'static str` the schema would say `type: string` and
anything would validate. That direction is held by the compiler instead,
via the exhaustive matches.

## Notes for review

- `schema_document()` lives in `json_v2.rs` beside the types, not in
`help.rs`, so `--print-schema` and the test compile the same document
rather than two constructions that could drift on the contract setting.
- The lychee exclusion for `worktrunk.dev/schema/` follows the entry
directly above it: a generated link that 404s until the site deploys.

> _This was written by Claude Code on behalf of max-sixty_
2026-08-05 22:33:23 -07:00
Maximilian Roos e1745db105 feat(list): abbreviate the table's SHA with git, not a fixed slice (#3676)
The Commit cell sliced `&head[..8]` while `--format=json`'s `short_sha`
carried git's `%h`, so one commit read `1b9f1d96` in the table and
`1b9f1d9` in JSON, and `core.abbrev` reached only the JSON. #3675 gave a
detached row's Branch cell the same slice, so the disagreement showed up
twice on one row.

`ListItem::short_sha` becomes the only abbreviation of `head` anywhere:
the Commit cell, a detached row's Branch cell, the statusline, and JSON
all render it. `abbreviated_head()` is gone.

## Column widths

`COMMIT_HASH_WIDTH = 8` is gone. The Commit column and the Branch
column's detached budget both measure the SHAs they will render, so
`core.abbrev = 12` no longer truncates mid-hash and the default 7 stops
reserving a column nothing fills — the freed character goes to Message.

## Latency

`collect()` folds `%h` onto the rows before layout instead of after the
skeleton. The batch carrying it already gates the skeleton for `%ct`
sort order, so this is a map lookup rather than new I/O, and both cells
are identity columns with no placeholder — they still paint in the first
frame.

Measured on a 40-worktree / 400-branch fixture: git subprocess counts
are identical (5 pre-skeleton, 108 for the full run). Pre-skeleton wall
time is unchanged; running both binaries in each order, the sign of the
difference follows run order rather than the binary (+1.5 ms with this
branch second, −0.5 ms with it first), so the residual sits inside
drift.

## Behavior change

Where the commit-details batch fails, the Commit cell is now empty
rather than a slice of a SHA git refused. Age and Message already report
that failure the same way, under the same warning, and two snapshots
show it. `render_text_cell` also stops styling empty text, so a blank
cell no longer emits an escape pair around nothing.

## Reading the diff

160 files, but the hand-written part is +91/−79 in `src/commands/list/`
plus a +71 test. The rest is generated. The docs mirrors and help
snapshots are symmetric. Of the snapshot lines, content is +799/−799 —
every changed line a 1-for-1 hash swap — while +1396 is insta `env:`
metadata refreshing on the 128 snapshots this happens to touch.

`test_list_abbreviated_sha_follows_git` pins the invariant: the table's
hash equals JSON's `short_sha` at git's default and at `core.abbrev =
12`, and a longer prefix is ruled out. It fails against the old fixed
slice.

> _This was written by Claude Code on behalf of max-sixty_
2026-07-30 18:33:30 -07:00
Maximilian Roos 9eb473e056 feat(list): name a detached row by its short hash, not - (#3675)
The Branch cell hardcoded `"-"` for a worktree with no branch. It reads
as missing data rather than as a state, and it was the odd one out: the
skeleton row, the statusline, and `worktree_display_name` all reach for
`branch_name()`'s `"(detached)"`, so the same cell changed label as the
row settled. Detached worktrees aren't exotic here any more — Codex
creates one per session under `~/.codex/worktrees/`, and they sit in `wt
list` alongside everything else.

The cell now carries the row's abbreviated HEAD in dim yellow. Yellow
keeps it from reading as a branch that happens to be named like a SHA;
dim keeps a row that isn't on a branch quieter than one that is.
`should_dim`'s removable dim still reaches the row's Path and Message
cells, so that signal survives the override.

```
  Branch      Status  Path        Commit          Branch      Status  Path        Commit
@ main            ^|  .           1243e9c0      @ main            ^|  .           1243e9c0
+ -            ! ⚑↓   ../codex/…  bdc5c663  →   + bdc5c663     ! ⚑↓   ../codex/…  bdc5c663
+ 1p              ⊂   ../wt.1p    bdc5c663      + 1p              ⊂   ../wt.1p    bdc5c663
```

### What to look at

`display_name()` gains a HEAD-prefix fallback so it answers before the
`%h` batch lands post-skeleton — the skeleton and settled rows now print
the same text in the same style, with no restyle as the row fills in.
Both the Branch cell of a detached row and the Commit cell of every row
render the new `abbreviated_head()`, so one commit gets one spelling:
sourcing the Branch cell from `short_sha` (`core.abbrev`-aware) instead
put `1b9f1d9` beside the Commit column's `1b9f1d96` on the same row.

The Branch column budgets `COMMIT_HASH_WIDTH` when any row is detached.
Sized off branch names alone it truncated the hash — and it already
truncated the skeleton's `(detached)` to `(detac` behind a short branch
set, so that was a latent bug rather than a new constraint.

The picker's matcher text and the statusline follow the display. A
detached row now filters by the hash on screen rather than a
`"(detached)"` token that matches nothing visible and collapses every
detached row onto one key, and a prompt names the same worktree the same
way its `wt list` row does.

`⚑` on a detached row stays as it was. The flag's axis is "not at home",
and a worktree with no branch has no home path to be at — but the docs
described only "branch name doesn't match the worktree path", which
doesn't cover the case that has no branch at all. They now name it.

### Testing

Covered by the existing detached-head list snapshots (all four now show
the hash), a new layout test pinning the column width against a short
branch set, a unit test for the `display_name` / `abbreviated_head` pair
across the skeleton boundary, and the statusline detached test rewritten
to assert the hash. Full suite green locally: 4493 tests, clippy and
pre-commit clean.

> _This was written by Claude Code on behalf of max-sixty_
2026-07-30 16:01:13 -07:00
Maximilian Roos 79824f7122 feat(styling): underline every hyperlink (#3643)
The statusline underlined its PR reference but not the dev-server port,
so nothing marked the port as clickable:

```
~/w/worktrunk.test-suite-cpu  ↕|💬  ↑17 ↓11  ^+585 -258  #3604  :11486  Fable 5  🌕 30%
```

Both links now route through a shared
`worktrunk::styling::hyperlink(url, text)`, which emits the OSC 8
sequence and the underline together. It closes with `[24m` rather than a
full reset, so a wrapping color (the CI verdict) or dim (a port nothing
answers on) survives the link.

The rule is recorded as policy in the `writing-user-outputs` skill. Link
text is sized to fit a column (`#3604`, `:11486`) and reads as ordinary
content, and color already carries state, so the underline is the only
thing marking text as clickable. Text that is not a link stays plain: on
a terminal without OSC 8 support, `wt list` still prints the dev-server
URL in full, unadorned.

Tests: a unit test pins the helper output, and a statusline test asserts
both segments carry a helper-built link. Snapshots updated for the
reordered escapes.

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

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:04:15 -07:00
Maximilian Roos 04d8d587f2 feat(list): flag a branch checked out in more than one worktree (#3606)
## Problem

Follow-up to #3480. That PR made a duplicated branch checkout (`git
worktree add --force <path> <branch>`) visible at *resolution* time:
`worktree_for_branch` warns once per branch, then resolves to whichever
worktree git lists first. `wt list` said nothing about it. Two rows
named `feature`, and no column that explains why.

The nearest thing to a signal was accidental. A force-added duplicate
usually lands off-template, since the original holds the template path,
so it picks up `⚑` for the location mismatch — while the worktree *at*
the template path, the one `wt` actually resolves to, carried no flag at
all. Exactly backwards from what's useful.

The framing from the request: a worktree in the wrong location gets a
status flag; a worktree sharing its branch should get one too.

## Solution

`⚑` now covers both, on every worktree of the duplicated branch,
resolved one included. Which worktree `wt` picks is git's listing order,
so singling out the shadowed rows would imply a legitimacy the ordering
doesn't carry.

```
@ main           ^|                                      |     .                    05a4a45d  16h   Initial commit
+ feature       ⚑_                                             ../repo.feature      05a4a45d  16h   Initial commit
+ feature       ⚑_                                             ../repo.feature-dup  05a4a45d  16h   Initial commit
```

This started as a seventh glyph (`⧉`) and collapsed onto `⚑` in the
second commit. The Status column is a dense alphabet the reader has to
learn, and the two states say one thing: this worktree's place in the
branch ⇔ worktree map is irregular. Off-template path and
branch-claimed-twice are both instances. The table already distinguishes
them without a glyph — a repeated Branch cell is the duplicate, an odd
Path cell the mismatch — so the flag only has to say "not a rendering
glitch, look at the Path column". Sharing the glyph means sharing its
dim-yellow styling, since the codebase treats a symbol's color as part
of its identity; #3480's warning remains the loud channel, firing the
moment any command resolves the branch.

**The Path column comes along.** It previously appeared only for a
location mismatch, on the reasoning that the path is otherwise redundant
with the branch. A duplicate inverts that: the branch name no longer
identifies the row, and the path is the only thing telling the two
apart. The layout flag is renamed from `has_branch_worktree_mismatch` to
`path_is_informative` to say what it now means.

**The data model keeps the distinction.** JSON has no cardinality
budget, and reporting a duplicate that sits at the template path as
`branch_worktree_mismatch` would be false — its path does match. Schema
1's `worktree.state` names the cause (`"duplicate_branch"`), schema 2
gets its own `worktree.duplicate_branch` bool beside `branch_mismatch`,
matching that schema's one-fact-per-field shape. The priority between
the two `⚑` states now decides only which cause JSON reports.

Detection is one pass over the worktree list (`duplicated_branches`,
next to #3480's `worktree_paths_for_branch`), in memory, pre-skeleton,
no git calls.

## Testing

- `test_worktree_paths_for_branch_detects_duplicates` gains the set form
and a detached-HEAD worktree, which has no branch to duplicate.
- `test_metadata_worktree_state_priority` covers the two `⚑` states'
ordering and both yielding to `⊟`/`⊞`.
- `test_list_duplicate_branch` snapshots the table above, showing both
flagged rows and the Path column earning its place.
- `test_list_duplicate_branch_json` asserts both schemas flag exactly
the two duplicated rows.

The flag only fires on a state no prior test sets up, so the only
snapshot churn is the help pages and the schema-2 envelope's new field.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:16:22 -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 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 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 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 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 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 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 830cc850dd feat(list): show the main…± column by default; --full gates only off-machine columns (#3236)
Move the `main…±` branch-diff column (line diffs since the merge-base) into the default `wt list` view — it's pure local git backed by a persistent content-addressed cache, so the original blocking-walk concern no longer applies. `--full` now gates only the two off-machine columns: CI status (network) and LLM branch summaries. The interactive picker (`wt switch`) follows suit and is effectively `wt list --full`; on narrow terminals with the preview shown, CI clips past the split and alt-p reveals it.

Also adds a `.typos.toml` ignore rule for truncated word fragments glued to the … ellipsis, so the narrower Message column's truncated quickstart embed doesn't get spell-"corrected" by pre-commit.ci.
2026-06-25 01:49:47 -07:00
Maximilian Roos c3abee15ef docs(list): note --full requirement on summary and main.diff JSON fields (#3224)
Follow-up to #3220. That PR added the `--full` qualifier to the JSON
`ci` field row; the `summary` field and the `main` object's `diff`
sub-field have the identical omission. Both are gated by `--full` in the
same `skip_tasks` block (`SummaryGenerate` and `BranchDiff` are dropped
when not `--full`), and both Columns-table rows already say "`--full`
only" while their JSON field rows didn't — so a consumer scripting plain
`wt list --json` would see `summary`/`main.diff` always absent and
misread the cause.

This states the requirement on both rows, so all three `--full`-gated
JSON surfaces (`ci`, `summary`, `main.diff`) read consistently. Edited
in `src/cli/mod.rs` (the `after_long_help` primary source); the
`docs/content/list.md` and skill-reference mirrors plus the two `--help`
snapshots are regenerated.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:52:41 -07:00
Maximilian Roos 2f47f29a6f docs(list): note --full requirement on the JSON ci object (#3220)
The `wt list --json` Fields table described the `ci` object as "absent
when no CI", which omits the actual gate: `ci` is only populated under
`--full` (or `[list] full`, or the statusline JSON path) — the same
`--full` requirement the Columns table and the `[list] columns` note
already document. A consumer scripting plain `wt list --json` sees `ci`
always absent and reasonably misreads it as "no CI configured" rather
than "needs `--full`".

This states the requirement on the field row: `--full` only, then absent
when no PR/MR or branch workflow. Source edit is in `src/cli/mod.rs`
(the `after_long_help` primary source); the `docs/content/list.md` and
skill-reference mirrors plus the two `--help` snapshots are regenerated.

> _This was written by Claude Code on behalf of max_

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

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

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

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

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

### Testing

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

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:46:06 -07:00
Maximilian Roos 61f3a296d9 docs(list): align status-symbol tables with JSON by type (#3139)
Restructure the `wt list` "Status symbols" docs so each subcolumn names its `--format=json` field, along the type the data actually has: Working tree is a product type (independent booleans that co-occur) and gets a Symbol | field | Meaning mapping; Worktree, Default branch, and Remote are sum types (one symbol at a time) and get Symbol | JSON | Meaning bridge tables. Fix the intro's "only the first matching symbol is shown", which was wrong for the co-occurring working-tree flags. Single-source the JSON-section meanings via links back to the bridges.

Also correct an unreachable JSON value: the `/` symbol was documented as `worktree.state "no_worktree"`, but branch-kind items emit no `worktree` object, so it's mapped to `kind "branch"` and dropped from the worktree-object value list (matching the JsonWorktree.state doc comment).

Docs-only change to `src/cli/mod.rs` after_long_help (primary source); mirrors and help snapshots regenerated.
2026-06-21 15:31:03 -07:00
Maximilian Roos a17fc22904 Add --config-set for inline TOML config overrides (#3138)
A global, repeatable `--config-set <toml>` flag that overrides any
user-config key for a single invocation, layered above config files and
`WORKTRUNK_*` env vars. The value is a real TOML fragment, so arrays and
tables work natively — no bespoke `key=value` grammar.

## Behavior

- **Precedence**: config files → `WORKTRUNK_*` env vars → `--config-set`
(highest).
- **Merge semantics**: a later override replaces an earlier one for the
same key; scalars and arrays replace the lower-layer value; nested
tables deep-merge, so `--config-set list.full=true` leaves sibling
`list.*` keys untouched.
- **Global**: works in any position (`wt --config-set … list` or `wt
list --config-set …`), like `-v` / `-y` / `--config`.
- **Graceful degradation**: a malformed, ill-typed, or invalid override
drops the whole `--config-set` layer with an attributed warning (`▲
Ignoring --config-set overrides: …`) and preserves the lower layers —
consistent with how the existing `WORKTRUNK_*` env overlay degrades.

## Why

This is the foundation for a follow-up that parameterizes `wt list`'s
column set without baking it into config: e.g. an alias `list-fast = "wt
--config-set list.columns=[...] list"` renders a smaller view while
plain `wt list` stays full. Doing it as a generic config-override lever
(rather than a `list`-specific flag) keeps one canonical path and
composes with aliases and any future config key.

## Implementation

`Cli.config_override` (`--config-set`, global, `Vec<String>`) is stashed
in a process-global `OnceLock` (mirroring `set_config_path`) and applied
in `UserConfig::load_with_warnings` via `apply_cli_overrides` as the top
layer. New `LoadError::CliOverride` variant carries the raw values for
attribution; the warning is rendered in `emit_user_config_warnings`.

## Testing

8 unit tests on `apply_cli_overrides` (sets,
deep-merge-preserves-siblings, last-wins, array-replace, malformed,
type-mismatch, validation-failure, empty) and 3 integration tests
(overrides-the-file, malformed-warns-attributed,
works-after-subcommand). Full suite green: lib + 1828 integration,
clippy clean, docs in sync.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:04:19 -07:00
Maximilian Roos a7e29f79c7 feat(list): custom template columns and cached PR numbers in the picker (#3073)
Two display features for `wt list` and the interactive picker, developed
together because they share the column-layout and progressive-rendering
machinery.

## Custom columns (`[list.custom-columns]`)

Each `[list.custom-columns.<Header>]` entry in user config adds a `wt
list` column: a minijinja template rendered per row over `branch`,
`worktree_path`, `worktree_name`, and `vars.*`, with optional `width`
and drop priority. Values expand before the skeleton renders, from
in-memory data only — `vars` come from the bulk git-config snapshot, so
no subprocess runs per cell. Widths are measured from content like the
Branch and Path columns; a column that is empty on every row is dropped.

Unknown variables and misspelled filters abort `wt list` with the
available-variables hint; undefined values render as empty cells (the
intended sparse-column shape). `wt list --format json` gains a `columns`
map per item, and its `vars` field now reads from the snapshot too (the
previous `--get-regexp` line-parse truncated multiline values). The
picker shares the row renderer, so the columns appear there as well; a
broken definition degrades to no columns plus a stashed warning, since
collect runs while skim owns the terminal.

The key is `[list.custom-columns]`, not `[list.columns]`, to avoid
colliding with the column-visibility toggles in #3065 (which claims
`[list.columns]` as a flat map of built-in-column bools — a mutually
exclusive serde shape for the same protected key). Namespacing here lets
both land independently.

Ref #1982 — the custom-columns proposal lives in that thread. The
issue's own title is a separate directory-naming request, so this
doesn't close it.

## Cached PR/MR numbers in the picker

The picker skips the networked CiStatus task, so until now it had no CI
column at all. Cached statuses are local data, though: collect now fills
rows from `.git/wt/cache/ci-status/` when the task is skipped under a
progressive handler, so PR/MR numbers fetched by earlier `wt list
--full` or statusline runs render in the picker — aligned with the same
`MaxPrNumber` ratchet width `wt list` uses, and with zero network
access.

A valid cache entry renders as-is. An entry whose TTL passed or whose
branch head moved keeps its PR/MR number dimmed: the number still
identifies the PR when the pipeline color may be outdated. Expired
entries without a number are dropped. The CI column is allocated only
when some row had a usable entry, and rows the cache can't fill resolve
to blank rather than a pending placeholder, since no task repaints them.

## Key files

- `src/config/expansion.rs`, `src/config/user/sections.rs`,
`src/git/repository/config.rs` — column resolution, the template
environment, and the bulk git-config snapshot.
- `src/commands/list/layout.rs`, `src/commands/list/render.rs` — column
width allocation and cell rendering.
- `src/commands/list/ci_status/mod.rs` — `populate_from_cache`, the
cache-only fill.
- `src/commands/picker/mod.rs` — the dry-run dump
(`WORKTRUNK_PICKER_DRY_RUN`) that makes picker row content assertable in
tests.

## Testing

Integration tests cover both features: custom columns (table render,
JSON output, empty-column drop, invalid-template error) and the picker
(cached PR numbers appear in the dry-run dump, uncached branches stay
blank). Unit tests cover the cache-population logic (valid,
expired-with-number, head-moved, dropped). Verified against the full
`cargo run -- hook pre-merge --yes` gate locally.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-19 11:37:22 -07:00
Jeremy Odell 0681a2e0d0 feat(list): add structured repo metadata to JSON output (#3021) 2026-06-17 23:45:18 -07:00
Maximilian Roos 7421236021 feat(list): gutter sigils for local (/) and remote (|) branches (#3115)
The interactive `wt switch` picker widens its candidate list with
`--branches` (local branches without a worktree) and `--remotes` (remote
branches), but the shared list/picker gutter rendered a blank cell for
every row without a worktree — so a local-branch-without-worktree and a
remote branch looked identical. This adds gutter sigils that distinguish
the three row kinds.

## Scheme

The gutter marks each row by physical presence:

```
@ main                 worktree — current     (bright)
^ main-repo            worktree — primary     (bright)
+ feature-x            worktree — other       (bright)
/ local-feature        local branch, no worktree   (dim)
| origin/remote-feat   remote branch               (dim)
```

`@`/`^`/`+` are unchanged. The two new glyphs echo their Status-column
twins — `/` is the WORKTREE_STATE "branch" glyph and `|` the in-sync
upstream glyph, both already rendered dim — so the gutter reads as a
left-edge summary of the row's kind, and bright-worktree / dim-ref
reinforces the presence gradient. The glyph is the primary signal
(survives `NO_COLOR`); dim is reinforcement only. All glyphs are
single-width ASCII, deliberately, to dodge skim's `width_cjk` clipping
(see `vendor/NOTES.md`) — the gutter is the worst place for a clipped
row.

## Key decisions

Local vs remote is recorded structurally as
`ItemKind::Branch(BranchScope)` at construction (`new_branch` /
`new_remote_branch`), not inferred from the branch name — a local branch
may legitimately be named `origin/foo`, so a name-prefix heuristic would
misclassify it. The gutter renders in both the final and skeleton paths
with matching dim, so there's no flash or flicker on progressive reveal.

A `#` sigil for PR rows (open PRs with no local branch) is intentionally
**not** built here — see `TODO(pr-rows)` in `render.rs`. That row kind
lives on a separate branch, and the picker skips CI/PR detection (a
network call), so a PR-driven `#` would never render in the picker
regardless. The scheme leaves `#` free for it.

## Files

- `src/commands/list/model/item.rs` — `BranchScope` enum,
`gutter_sigil()`, `new_remote_branch`.
- `src/commands/list/render.rs` — gutter rendering (final + skeleton).
- `src/commands/list/collect/mod.rs` — remote rows use
`new_remote_branch`.
- `src/cli/mod.rs` — new gutter legend in `wt list --help` (the gutter
glyphs were previously undocumented); docs/skill mirrors regenerated.

## Testing

Covered by the existing `wt list` / `wt switch` snapshot suite
(regenerated under nextest); the rendered-table diffs are confined to
the gutter cell. JSON output is unchanged (both branch scopes still
serialize as `"branch"`; the remote-qualified name already carries the
distinction for scripts).

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:00:51 -07:00
Maximilian Roos 47180a7035 refactor(cli): split StatuslineFormat from OutputFormat (#3116)
## Summary

`OutputFormat` carried a `claude-code` variant that only `wt list
statusline` uses. That forced two unrelated commands to work around it:
`wt list` and `wt config state get` set `hide_possible_values = true`
and hand-maintained an `Output format (table, json)` doc string to keep
`claude-code` out of their help, and their match arms carried a dead
`ClaudeCode` branch.

This splits the shared enum (removing the long-standing TODO that called
for it):

- **`OutputFormat { Table, Json }`** — `wt list`, `wt config state get`
- **`StatuslineFormat { Table, Json, ClaudeCode }`** — `wt list
statusline` only

With `claude-code` gone from `OutputFormat`, both commands drop
`hide_possible_values` and the parenthetical, and clap renders the value
list itself:

```
--format <FORMAT>  Output format [default: table] [possible values: table, json]
```

`wt list statusline` is unchanged (`[possible values: table, json,
claude-code]`; `--help` keeps the expanded block with the `claude-code`
description).

## Behavior change (CLI flag)

This tightens parsing on two flags. Previously `wt list
--format=claude-code` and `wt config state get --format=claude-code`
were silently accepted and treated as `table`; they now fail fast:

```
error: invalid value 'claude-code' for '--format <FORMAT>'
  [possible values: table, json]
```

The value was hidden, undocumented, and meaningless on those commands
(only `statusline` ever used it), so this is a fail-fast improvement
rather than a meaningful break.

## Testing

- `cargo run -- hook pre-merge --yes` — clippy, all pre-commit hooks,
3951 tests, doctests: green
- `test_docs_are_in_sync` regenerated `list.md` mirrors; 4 help
snapshots updated

> _This was written by Claude Code on behalf of max_

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

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

## The width problem

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

## Reviewer's map

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:36:44 -07:00
Maximilian Roos 214d82aeda Add PR/MR review state to wt list CI status (#3044)
Adds PR/MR review state to `wt list`'s CI status, merged into the
existing CI dot color.

## What

- New `ReviewState` (`approved` / `changes_requested` / `pending` /
`draft`) on `PrStatus`, absent when the forge reports no review signal —
branches with no review activity render exactly as before.
- **GitHub**: `reviewDecision,isDraft` join the existing single `gh pr
list --json` call (no extra API cost). Draft wins over the decision; an
empty `reviewDecision` (no required reviewers, no reviews) maps to
absent rather than `pending`, so solo repos don't show a perpetual
waiting state.
- **GitLab**: `draft` + `detailed_merge_status == "not_approved"` →
`draft`/`pending`; MR list data carries no approved/changes-requested
signal. Gitea/Azure: none.
- **Display**: review state merges into the CI dot in
`PrStatus::color()` — conflicts (yellow) > changes-requested
(**magenta**) > running (blue) > failed (red) > review-required
(**cyan**) > base CI color; draft dims like staleness. Cool colors mean
waiting (blue: on CI, cyan: on a human), warm mean act;
changes-requested outranks running because waiting can't clear it.
Magenta was the one color not already carrying a meaning in the list
row; cyan is reused from the working-tree symbols column, where the
differing glyph disambiguates.
- **JSON**: `ci.review_state` in `wt list --format=json`, vocabulary
matching Claude Code's statusline `pr.review_state` so the two surfaces
agree on names.

## Notes for review

- The merge lives in `PrStatus::color()`/`style()` — `CiStatus` stays
pure CI (cache values and JSON `status` strings unchanged). Both the
table and the statusline render through `format_indicator` → `style()`,
so there's a single chokepoint.
- Old CI cache files deserialize unchanged (the new field is an
`Option`).
- `docs/content/list.md` and `skills/worktrunk/reference/list.md` are
regenerated from `src/cli/mod.rs`. The HTML and terminal help colorizers
learned magenta/cyan (they previously mapped only the five existing
colors). Also fixed a pre-existing missing `repo_url` row in the
ci-object doc table.
- Testing: unit tables for the color merge and both forge mappings;
integration snapshots (mocked `gh`) for changes-requested /
review-required / approved / draft verifying the exact ANSI codes; the
JSON field name is pinned by an inline snapshot.

If this change is bad, it's most likely because the dot now encodes two
axes (CI health and review attention) in one color — a consumer who
reads green as "CI passed" still gets that, but red-vs-magenta now
distinguishes who rejected the branch, which is more vocabulary to
learn. The `--format=json` output keeps the axes separate for anyone who
wants their own logic.

Ref #2950

> _This was written by Claude Code on behalf of max_

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

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

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

## Factual corrections

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

## Test fix: narrow help-test env redaction

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

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:00:52 -07:00
Maximilian Roos 2617348b22 docs: writing-prose cleanup (faq, config, list, remove) (#2925)
Continues the writing-prose pass on the remaining doc-site pages — the
items deferred at the end of #2922.

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

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

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

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

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

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

---------

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

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

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

## Change

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

## Notes for review

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

## Tests

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

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:55:03 -07:00
Worktrunk Bot a77c92e25b docs(help): point banner at the actual cli source path (#2665) 2026-05-10 08:14:37 +00:00
Maximilian Roos 3593606283 refactor: route every short-SHA display through git's abbreviation logic (#2576)
Every site that abbreviated a commit SHA was either slicing `&sha[..7]`
or running its own ad-hoc `git rev-parse --short` call. 7-char prefixes
regularly collide in repos with many commits, and none of the slicing
sites honored `core.abbrev`. Cut over to a single canonical helper.

## Single helper, every display site

`Repository::short_sha(&str) -> Result<String>` wraps `git rev-parse
--short`. Routes display through git's own abbreviation logic so
`core.abbrev` is honored and prefixes auto-extend on collision. Used by:

- `step commit` / `step squash` success lines
- `step push --no-ff` `Merged to @ <hash>` line — the original bug.
Flagged on #2560 as a pre-existing third instance of the same pattern
fixed there in `commit.rs` and `step_commands.rs`.
- `{{ short_commit }}` template var in hook contexts
(`command_executor.rs`, `template_vars.rs`)
- post-remove hook context for the removed worktree
- safety-backup ref display (`create_safety_backup`)
- `(detached <sha>)` label in the orphan-check loop

All seven sites previously sliced `commit[..7]` or called their own
`rev-parse`. Now they route through one helper.

## Batched form for `wt list --format=json`

The JSON list path emits one `short_sha` per worktree row. Looping
`short_sha` would fork a subprocess per row, so the short SHA is folded
into the existing `commit_details_many` batch instead — `%h` is added to
the `git log --no-walk --format=...` call that already fetches timestamp
and subject. One subprocess for the whole list, same `core.abbrev`
behavior as every other site.

`CommitDetails` gains a `short_sha: String` field with the same
provenance as the timestamp and subject. The JSON schema is unchanged
(`commit.short_sha` was already a field) — only its length now varies by
`core.abbrev` instead of being hard-coded to 7.

## API change

`TemplateVars::with_active_commit(commit, short_commit)` now takes both
forms. Previously it sliced `commit.get(..7)` internally. The sole
caller (`worktree/finish.rs`) resolves the short form via
`Repository::short_sha` and passes both.

## Docs

`{{ short_commit }}` is no longer documented as "(7 chars)" —
`src/cli/mod.rs` and `src/config/project.rs` now describe `core.abbrev`
behavior. Doc-sync regenerated `docs/content/hook.md` and the skill
mirror.

## Tests

Full suite passes (3463/3463). `commit_details_many` tests updated for
the new tuple shape; `CommitDetails` fixtures in `layout.rs` get a
`short_sha` field; `template_vars` tests pass both forms explicitly. Two
integration tests previously asserting `short_commit` was exactly 7
chars (`post_start_commands.rs`, `user_hooks.rs`) still pass — fresh
test repos default to `core.abbrev = 7`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 23:17:03 -07:00
Maximilian Roos 4cbc8ca5fd refactor(docs): drop mirrored help-page close; align convert_console_blocks_in_docs error channel (#2422)
## Summary

Three follow-ups from #2419 review.

- **Bare close everywhere.** With inner snapshot wrappers gone from
command pages (#2419), no `AUTO-GENERATED` markers nest inside the
help-page region anywhere in `docs/content/*.md`. The mirrored close
(`<!-- END AUTO-GENERATED from \`wt cmd --help-page\` -->`) was the only
remaining variant of the close form; collapsed to bare `MARKER_CLOSE`.

  - `src/help.rs::PageMode::emit_footer()` no longer takes `subcommand`.
- The help-page regex in `readme_sync.rs` matches via non-greedy `.*?`
to bare `MARKER_CLOSE`, with a comment pointing at the new invariant
test.
- `AUTO_GENERATED_MARKER_PATTERN` strip regex built from the constants.

Added **`test_no_nested_auto_generated_markers`** — walks
`docs/content/*.md` and fails if any `AUTO-GENERATED` open ever appears
inside an already-open region. This is the explicit invariant that
bare-close pairing depends on; if a future change tries to re-introduce
nesting (e.g., restore an inner snapshot wrapper around terminal
shortcodes), the test catches it before the subtle "regex chops region
at first inner close" failure mode lands.

- **Aligned error channel.** `convert_console_blocks_in_docs` now
returns `(Vec<String>, Vec<String>)` like its sibling sync steps, with
per-file error capture for `read_dir`, dir entries, and
`read_to_string`. The caller passes errors through the same `tag()`
aggregation as everything else, so a transient I/O failure on one file
no longer aborts the whole pipeline silently. Also fixes the matching
clippy warning (`is_some_and(|e| e == \"md\")` → `is_none_or(|e| e !=
\"md\")`).

- **Docs alignment.** `docs/CLAUDE.md` updated in two places where the
prose still documented the mirrored close as the canonical form.

Visual check via curl across 12 dev-server pages confirmed no leakage of
any prior marker form. Adversarial review (subagent /popper-style)
caught the stale prose; otherwise no regressions found.

Net diff: +113/-49 (the growth is the new invariant test and explicit
per-file error handling; structural simplification shows as -49).

## Test plan

- [x] `cargo test --test integration readme_sync` — 12 sync tests pass
(was 11; +1 for the new invariant test)
- [x] `cargo test --test integration` — full integration suite (1558
tests) pass
- [x] `cargo test --test integration
test_no_nested_auto_generated_markers` — new guard test passes; manually
verified it fires on synthetic nesting
- [x] Single-pass convergence verified: `git checkout docs/ && cargo
test --test integration test_docs_are_in_sync && git diff --stat`
produces an empty diff after the first run
- [x] Visual check via local Zola dev server: 12 docs pages
(\`/worktrunk/\`, \`/llm-commits/\`, \`/claude-code/\`,
\`/tips-patterns/\`, \`/merge/\`, \`/step/\`, \`/remove/\`, \`/hook/\`,
\`/list/\`, \`/switch/\`, \`/config/\`, \`/faq/\`) — only HTML comments
contain marker text, no rendered leakage
- [x] `cargo clippy --all-targets --all-features` — clean
- [x] `cargo fmt --check` — clean

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-26 02:18:39 -07:00
Maximilian Roos 66a5becc25 refactor(tests): consolidate three sync tests; drop dead inner snapshot wrappers (#2419)
## Summary

Two follow-ups from #2418 review.

- **Test brittleness fix.** Three previously-separate sync tests had
data dependencies that nextest could interleave:
`test_readme_examples_are_in_sync` reads `docs/content/*.md`, which
`test_docs_quickstart_examples_are_in_sync` generates from snapshots.
Under parallelism, README sync could see stale docs and produce content
that required a second run to converge. Collapsed both into the existing
`test_command_pages_and_skill_files_are_in_sync` pipeline, renamed to
`test_docs_are_in_sync`. Steps run sequentially in dependency order;
single pass converges from a clean working tree.

Each step's errors and updated-file list are tagged with the pipeline
stage (`[command pages]`, `[standalone docs]`, `[README]`, etc.) so a
failure tells a developer which stage broke without reading the test
source. README failure also now reports `(N of M section(s) updated)`.

- **Dead inner snapshot wrappers.** `expand_command_placeholders`
wrapped each terminal shortcode in command pages with `<!-- ⚠️
AUTO-GENERATED from <snap> --> ... <!-- END AUTO-GENERATED -->` markers.
Command pages regenerate wholesale from `--help-page` each sync, so the
inner wrapper served no in-place-refresh purpose — it was dead weight
nested inside the outer help-page region's markers. Stripped from
`expand_command_placeholders`'s HTML branch; net -32 lines across six
command-page docs files.

The outer help-page region's mirrored close stays — it's load-bearing
whenever any nested `AUTO-GENERATED` marker appears in the body. Comment
in `src/help.rs:emit_footer` updated to explain the role. (My follow-up
note that the mirrored close was redundant turned out to be a
misanalysis — non-greedy `.*?` only safely pairs the open with the right
close when there are no nested closes; once the inner snapshot wrappers
were gone, the mirror became technically unnecessary, but keeping it
survives any future reintroduction of nesting at no cost.)

Renamed the test in `CLAUDE.md` (4 sites) and `docs/CLAUDE.md` (2 sites)
so the documented `cargo test` invocations resolve.

Net diff: −54 lines.

## Test plan

- [x] `cargo test --test integration readme_sync` — 11 sync tests pass
(was 13; minus the two collapsed)
- [x] `cargo test --test integration` — full integration suite (1557
tests) pass
- [x] **Single-pass convergence verified**: `git checkout docs/
README.md && cargo test --test integration test_docs_are_in_sync && git
diff --stat` produces an empty diff after the first run
- [x] Visual check via local Zola dev server: \`/worktrunk/\`,
\`/llm-commits/\`, \`/claude-code/\`, \`/tips-patterns/\`, \`/merge/\`,
\`/step/\`, \`/remove/\`, \`/hook/\`, \`/list/\` all render terminal
blocks cleanly with no leaked marker strings
- [x] Adversarial review of the consolidation (subagent /popper-style):
all 4 actionable findings (stale doc references, lost README count,
missing stage tags, etc.) addressed
- [x] `cargo clippy --all-targets --all-features` — clean
- [x] `cargo fmt --check` — clean

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 19:20:44 -07:00
Worktrunk Bot 5f126d21fe docs(pages): add static command output to sections dominated by GIFs (#2405)
## Summary

Adds static command-output blocks to the docs pages dominated by GIFs
(addresses #2403). The blocks are **driven from insta snapshots** so
they stay in lockstep with what `wt` actually prints — one source flows
to all three surfaces:

- terminal `wt <cmd> --help` (plain text, gutter-formatted)
- `docs/content/*.md` (colorized `{% terminal(cmd="...") %}` shortcode)
- `skills/worktrunk/reference/*.md` (plain `$ cmd\noutput\n` block)

## What changed

- **Six scripted snapshot tests** produce realistic output (`cargo
nextest run`, `flyctl scale count 0`, LLM-generated commit messages):
  - `test_docs_merge_pre_merge_hook` — `wt merge` with pre-merge hook
  - `test_docs_step_commit_llm` — `wt step commit` with LLM
  - `test_docs_step_squash_llm` — three-commit squash with LLM
- `test_docs_merge_squash_llm` — `wt merge` (squash + LLM + merge) for
`llm-commits.md`
- `test_docs_remove_pre_remove_hook` — `wt remove` with pre-remove hook
  - `test_docs_hook_pre_merge` — `wt hook pre-merge` direct invocation

- **Sync pipeline extension** in
`tests/integration_tests/readme_sync.rs`:
- New write-back pass `sync_cli_mod_example_bodies` fills the
```console``` body in each `<!-- wt <cmd> (docs-example) -->`
placeholder in `src/cli/mod.rs` from the registered snapshot. Runs
before `--help-page` reads the file.
- `COMMAND_PLACEHOLDER_PATTERN` extended to match three forms
(```bash```, `{{ terminal() }}` self-closing, `{% terminal %} body {%
end %}`). This **fixes a pre-existing bug** in `docs/content/list.md`
where the HTML-mode expansion was silently broken.
- Stripped trailing `|||` corruption that arises when blank lines in
snapshot bodies are interpreted as empty commands by
`convert_dollar_console_to_terminal`.

- **Docs page migration**:
- `src/cli/mod.rs` — replaced four hand-written ```console``` blocks
(merge, step, remove, hook) with `<!-- wt <cmd> (docs-example) -->`
markers.
- `docs/content/llm-commits.md` — replaced three hand-crafted HTML
blocks with `<!-- ⚠️ AUTO-GENERATED-HTML from X.snap -->` markers.

- **Refactor follow-up** in a separate commit:
- `BADGE_EXPERIMENTAL_HTML`, `SUBDOC_MARKER_PREFIX`,
`DEMO_MARKER_PREFIX` hoisted to `worktrunk::docs` so producer and
consumer stay in lockstep.
- `normalize_clap_help_fences()` consolidates the `text→` +
`console→bash` replacement pair shared by `--help-md` and
`help_reference_inner`.

- **CLAUDE.md note** documenting the `.gitattributes`
`linguist-generated=false` exemption requirement when adding skill-only
files (carryover from #2409).

## Test plan

- [x] `cargo test --test integration readme_sync` — all 13 tests pass,
idempotent
- [x] `cargo test --test integration test_help` — help snapshots updated
- [x] `cargo run -- {merge,remove,step,hook} --help` — clean gutter
formatting, no visible HTML comments
- [x] `cargo run -- hook pre-merge --yes` — full project gate green
(3355 tests)
- [ ] Visual check on dev site (maintainer — terminal shortcodes now
render with colors via ANSI→HTML; dark/light variants both expected to
look like the GIFs they replace)

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

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-04-24 16:40:21 -07:00
Maximilian Roos 0253260503 Extend -v variable dump to aliases + help-table drift test (#2324)
Follow-ups from #2316.

## What's in here

1. **Alias `-v` variable dump.** Added `format_alias_variables(ctx)`
alongside the existing `format_hook_variables(hook_type, ctx)`, with a
private `format_variables_table` helper sharing the alignment +
`(unset)` logic. Wired into `run_alias` before the announcement,
symmetric with the foreground hook path.

2. **Help-table drift test.**
`test_template_variables_table_matches_constants` parses the `##
Template variables` table out of `src/cli/mod.rs`, extracts `(kind,
var_name)` pairs, and asserts presence + group placement against
`ACTIVE_VARS` / `REPO_VARS` / `EXEC_BASE_VARS` / `ALIAS_ARGS_KEY` /
union of `vars_available_in(Hook(*))`. Uses public API only — no leak of
the private `hook_extras` helper. Adding a var to the constants without
updating the table (or vice versa) fails the test. Descriptions stay
free-form.

3. **Shorter `-v` help text.** `Verbose output (-v: info logs +
hook/alias template variable & output; ...)`.

## Example

```
\$ wt -v greet world
○ template variables:
  branch                = feature
  worktree_path         = _REPO_.feature
  worktree_name         = repo.feature
  …
  args                  = ["world"]
  repo                  = repo
  …
  cwd                   = _REPO_.feature
◎ Running alias greet
○ Expanding greet
  echo hello {{ args }}
  →
  echo hello world
hello world
```

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:39:29 -07:00
Maximilian Roos eec3b24883 Show resolved template variables under -v for hooks (#2316)
Closes #2309.

When a hook fires under `-v`, `wt` now prints a `template variables:`
block listing every variable in scope for that hook type and the value
it resolved to for this specific invocation. Vars that are in scope but
not populated render as `(unset)` — which is exactly how
`target_worktree_path` surfaces during `wt switch -`, the thing the
issue reporter hand-rolled an echo-hook to discover.

The block prints *before* the `◎ Running …` announce line so it
describes what the hook is about to see, not what already ran. Works in
both paths: the foreground path (`announce_command`, one block per
command) and the background path (`announce_and_spawn_background_hooks`,
one block per distinct hook type in the pipeline batch).

## Key files

- `src/config/expansion.rs` — `format_hook_variables(hook_type, ctx)`
emits the aligned `name = value` block ordered per the docs table
(active → operation → repo → exec). `BASE_VARS` is split into
`ACTIVE_VARS` / `REPO_VARS` / `EXEC_BASE_VARS` with a `base_vars()`
helper, so `vars_available_in` and the printer share one source of
truth.
- `src/commands/command_executor.rs` — foreground wiring in
`announce_command`, gated on `verbosity() >= 1`.
- `src/commands/hooks.rs` — background wiring in
`announce_and_spawn_background_hooks`; prints one table per distinct
hook type (since within a pipeline only `hook_name` varies).
- `src/cli/mod.rs` — updates the global `-v` help text and adds a
pointer under `## Template variables` in the hook help.

## Testing

- Unit: `test_format_hook_variables_groups_and_unset` snapshots a
pre-switch context with `target_worktree_path` omitted so `(unset)` is
covered; `test_format_hook_variables_scope_filters_operation` confirms
pre-commit's narrower operation scope.
- Integration: `test_hook_verbose_prints_variable_table` covers the
foreground path; the existing
`test_post_start_verbose_shows_per_hook_output` now also exercises the
background path.

No new CLI surface, no new config keys — only behavior added behind the
existing `-v` flag.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:28:43 -07:00
Maximilian Roos 1a4eea3c03 feat(cli): promote --yes to a global flag (#2279)
## Summary

`-y, --yes` is now a top-level global clap flag. It moves off every
subcommand's clap args and lives once on `Cli`, so `wt -y <anything>`,
`wt <anything> --yes`, and `wt --yes <anything>` all skip any approval
or confirmation prompt for that invocation.

## Why

Follow-up to the top-level alias dispatch (#2266). The long-term plan is
to unify approval-bypass behavior — `-y` should be a true global rather
than duplicated across switch, remove, merge, commit, squash, prune, all
ten hook subcommands, shell install/uninstall, plugin install/uninstall,
and config update. A single canonical flag removes ~50 lines of
duplicated clap definitions and one source of drift.

## Call-site survey

Approval and confirmation prompt sources, as surfaced by `rg -l
'approve_|requires_approval|confirm_'`:

- `approve_hooks` / `approve_hooks_filtered` — reads `ctx.yes`, already
threaded via `CommandContext::new(..., yes)`. No change needed; the
global now feeds those call sites from `handle_*_command` in `main.rs`.
- `approve_command_batch` (merge, config `hook approvals add`) — takes
`yes: bool`. Receives the global.
- `approve_alias_commands` — takes `yes: bool`. Receives `global_yes ||
opts.yes` (see "Alias compat" below).
- `handle_configure_shell` / `handle_unconfigure_shell` /
`handle_config_update` / `handle_claude_install[_statusline]` /
`handle_claude_uninstall` / `handle_opencode_install` /
`handle_opencode_uninstall` — take `yes: bool` for confirmation-prompt
bypass. All receive the global.

## Alias compat

`AliasOptions` is hand-rolled (not clap) so it doesn't conflict with the
clap global. Its post-alias `--yes` parsing is intentionally preserved
here — `run_alias` does `let skip_approval = global_yes || opts.yes;` so
both `wt -y deploy` and `wt deploy --yes` work unchanged. Removing the
post-alias form is a separate cleanup, tracked by the user's long-term
simplification plan.

## Navigating the diff

- `src/cli/mod.rs` — new `yes: bool` on `Cli` with `global = true`,
`short = 'y'`, `help_heading = "Global Options"`, `display_order = 103`
(slots after `-v`). Removed the per-command `yes` field from
`SwitchArgs`, `RemoveArgs`, `MergeArgs`.
- `src/cli/step.rs` — removed `yes` from `CommitArgs`, `SquashArgs`, and
`StepCommand::Prune`. Also dropped `help_heading = "Automation"` from
the four step subcommands where the group was left with a single flag
(`commit`, `squash`, `for-each`, `prune`); those flags now render under
default Options instead of a single-item group.
- `src/cli/hook.rs` — removed `yes` from all ten hook subcommands
(`pre-switch`, `post-switch`, `pre-start`, `post-start`, `pre-commit`,
`post-commit`, `pre-merge`, `post-merge`, `pre-remove`, `post-remove`).
`--yes` stays in `KNOWN_HOOK_LONG_FLAGS` so the shorthand rewriter still
recognizes it as a real flag rather than a template variable.
- `src/cli/config.rs` — removed `yes` from
`ConfigShellCommand::Install/Uninstall`, `ConfigCommand::Update` (and
its now-redundant `conflicts_with = "yes"` on `--print`),
`ConfigPluginsOpencodeCommand::Install/Uninstall`,
`ConfigPluginsClaudeCommand::Install/Uninstall/InstallStatusline`.
- `src/main.rs` — `dispatch_command` takes `yes: bool`; each
`handle_*_command` accepts and threads it. `Cli` destructure adds `yes`.
- `src/commands/alias.rs` — `try_alias`, `step_alias`, `run_alias` take
`global_yes: bool`. `run_alias` OR's with `opts.yes` before calling
`approve_alias_commands`.
- `src/commands/custom.rs` — `handle_custom_command` accepts and passes
the global to `try_alias`.
- `docs/content/` + `skills/worktrunk/reference/` — auto-generated from
`--help-page`; the global appears under "Global Options" on every
subcommand.
- `tests/integration_tests/approval_ui.rs` — six new tests:
`test_global_yes_before_subcommand`, `test_global_yes_for_hook`,
`test_global_yes_for_alias`, `test_post_alias_yes_still_works`,
`test_global_yes_for_step_alias`,
`test_global_yes_on_command_without_approval`.

## Notes

- Does not remove `AliasOptions::yes` — deferred cleanup per the task
brief.
- Snapshot churn (~27 help files) is the expected fallout of adding a
global flag; each subcommand's `--help` now shows `-y, --yes` under
Global Options.

## Test plan

- [x] 3242 tests pass (`cargo run -- hook pre-merge --yes`)
- [x] Lints clean (clippy, cargo fmt, pre-commit)
- [x] Doc sync test passes after regeneration
- [x] New approval_ui tests cover before-subcommand, after-subcommand,
alias, post-alias, step-alias, and no-approval positions

> _This was written by Claude Code on behalf of Maximilian_

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-04-18 11:08:41 -07:00
Maximilian Roos 1d7630e6de docs(cli): trim filler in list and step help text (#2277)
Third pass over the docs, this round in `src/cli/mod.rs` (which
generates the command pages).

- **`list`** — drop "The table displays instantly and columns fill in as
results arrive." from the `--full` paragraph. The opening paragraph of
`list`'s `after_long_help` already says: "The table renders
progressively: branch names, paths, and commit hashes appear
immediately, then status, divergence, and other columns fill in as
background git operations complete." Full mode inherits that behavior.

- **`step`** — trim the `<alias>` bullet to match the two other alias
references in the file (lines 1843, 2020, both just "Command templates
that run as `wt <name>`."). Drop the now-stale `[experimental]` tag —
the Aliases section in `extending.md` stopped being marked experimental
in #2271, leaving this the only remaining reference — and drop the `(see
[Aliases](...))` parenthetical that linked to the same anchor as the
bullet name.

Snapshots updated via `cargo insta test --accept`.

Follows #2271 and #2272.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:58:08 -07:00
Maximilian Roos 3e1b351ac1 feat(log): split -vv output into trace.log + output.log, drop -vvv (#2201)
## Motivation

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

## Change

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

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

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

## Fallback path

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

## Key files

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

## Testing

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 10:50:18 -07:00
Maximilian Roos 7afa2533e3 Collapse Status loading/timeout placeholders to · (#2177)
The Status column's loading glyph `⋯` (horizontal ellipsis) was too
visually prominent in the tight per-position slots. Loading and the
post-deadline timeout state don't really need to be distinguished via
glyph — the surrounding context (progressive fill in `wt list`, picker
appearing after deadline in `wt switch`) already tells the user which is
which — so both states now render as a dim `·`.

The change is centralized in a new `PLACEHOLDER` constant in
`src/commands/list/render.rs`. Its TODO documents the future re-split:
we'd like a subtle second glyph once we can evaluate candidates
side-by-side in real tables.

`render_list_item_stale` is kept as a separate entry point (passing the
same `PLACEHOLDER` today) so the picker-side call site doesn't need
re-auditing when the re-split lands.

Most of the diff is mechanical: doc-comment references to the `⋯` glyph,
test helpers counting `·` instead of `⋯`, regenerated insta snapshots,
and the user-facing help table in `src/cli/mod.rs` collapsing two rows
into one.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 23:08:42 -07:00
Maximilian Roos 2a6389a092 Centralise [wt-trace] emitter and fix -vv log verbosity (#2146)
## Summary

Three changes to worktrunk's logging conventions, all motivated by `wt
list -vv` dumping raw `git diff-tree -p` bodies into the log stream at
debug level:

**1. `src/trace/emit.rs` owns the `[wt-trace]` grammar.** Previously the
grammar was emitted via ad-hoc `log::debug!("[wt-trace] ...")` format
strings in `shell_exec.rs`, with `src/trace/parse.rs` silently defining
it by how it parsed. `trace_instant`, `log_command_result`, the
`TRACE_EPOCH` static, and `thread_id_number` moved into the new emitter
module so `parse.rs` and the producer share one source. Wire format
byte-identical; `wt-perf` parsing unchanged.

**2. Level discipline.** `-v` → Info, `-vv` → Debug, `-vvv` → Trace.
Previously `-v` didn't touch the `log` crate at all and `-vvv` didn't
exist. The LLM prompt dump (`src/llm.rs`) and captured subprocess
stdout/stderr (`log_output` in `shell_exec.rs`) moved to `log::trace!`,
so `-vv` stops spilling thousand-line diff bodies and full LLM prompts.

**3. Bounded `log_output`.** At Debug, each stream caps at 200 lines /
64 KB with `… (N more lines, M bytes elided — use -vvv for full
output)`. At Trace, uncapped.

## Reviewer navigation

- **New file**: `src/trace/emit.rs` — the single-source emitter.
`command_completed`, `command_errored`, `instant` plus `trace_epoch` /
`now_us` / `thread_id` helpers.
- **Delete/delegate**: `src/shell_exec.rs` loses its duplicate
`TRACE_EPOCH` + `trace_epoch` + `thread_id_number`, and the four
`log::debug!("[wt-trace] …")` branches collapse into two calls into
`trace::emit`. `log_output` gains `log_stream_full` (Trace) and
`log_stream_bounded` (Debug) helpers.
- **Level map**: `src/main.rs:init_logging` has the new match on
`verbose_level`.
- **Help + test snapshots**: the `--verbose` help text change in
`src/cli/mod.rs:249` drives the large auto-synced snapshot / docs /
skill-reference diff.
- **Test rename**: `tests/integration_tests/diagnostic.rs` —
`test_v_does_not_enable_logging` → `test_v_does_not_write_log_files`
(the old name became inaccurate now that `-v` enables Info logging on
stderr).

## Testing

- 969 library unit tests pass.
- Integration tests for `diagnostic`, `step_alias`, `test_help` pass (82
tests).
- Lints clean.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 18:17:33 -07:00
Maximilian Roos d0bee7b260 docs: cross-link vars references to dedicated docs (#2034)
The vars feature is documented in three places (hook template variable
table, config state vars page, tips-patterns recipes), but these pages
didn't cross-reference each other. Readers encountering vars in one
place had no clear path to the other.

Added links so the two main vars pages link to each other
bidirectionally, and every other mention now points readers to either
the CLI reference (how to set/get) or the template reference (how to use
in hooks/aliases).

**Changes (5 locations — 4 in CLI source, auto-synced to docs/ and
skills/):**

- \`hook\` template variables table: \`{{ vars.<key> }}\` row now links
to \`wt config state vars\` docs
- \`wt list\` JSON output table: \`vars\` field now links to \`wt config
state vars\` docs
- \`wt config state vars\` "Template access" section: "hook templates"
now links to the template variables page
- \`wt config state\` keys list: the \`vars\` entry now links to its own
section
- \`tips-patterns.md\` "Database per worktree": "vars" now links to the
config page

No changes to aliases — their cross-references were already complete.

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

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

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

## Solution

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

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

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

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

## Known limitation

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

## Testing

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

Closes #1818

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

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-04-01 19:05:54 -07:00
Maximilian Roos 69edc3a337 feat: add wt config state vars for per-branch custom variables (#1006)
Adds `wt config state vars set/get/list/clear` commands for storing
custom variables per branch in git config
(`worktrunk.state.{branch}.vars.{key}`). Variables are available in all
template contexts via `{{ vars.key }}` syntax (hooks, `wt step eval`),
with JSON dot access (`{{ vars.config.port }}`) and default filters (`{{
vars.env | default('dev') }}`).

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

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

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

## Test plan

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-30 14:37:04 -07:00
Maximilian Roos bd9b9fe293 Syntect highlighting for template expression blocks (#1792)
Commands containing `{{ }}` template expressions (e.g., `wt step eval
'{{ branch | hash_port }}'`) were the only blocks that didn't get full
Syntect highlighting on the docs site — they fell back to accent-only
color because Tera would interpret `{{ }}` in the `cmd` parameter as
template expressions.

This uses text placeholders (`__WT_OPEN2__`, `__WT_CLOSE2__`) that pass
through Tera safely. The terminal shortcode template replaces them back
to real braces before Syntect processes them. Also fixes double-encoding
of `"` in cmd parameters (the old `&quot;` was getting double-encoded by
Syntect to `&amp;quot;`), using a `__WT_QUOT__` placeholder for the same
reason — Tera has no backslash-escape mechanism for string literals.

The skill file generator (`transform_docs_for_skill`) was updated to
handle the new format: extracting `cmd` parameter values and `|||`
delimiters into `$ `-prefixed bash blocks, converting legacy `<span
class="cmd">` body tags, and fixing the `[^)]*` regex that broke on `)`
inside cmd values.

Net effect: all code blocks on the docs site now have consistent
multi-color Syntect highlighting, and skill reference files have clean
`$ command` blocks instead of raw HTML.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-29 15:04:35 -07:00
Maximilian Roos 33b87f0b95 Widen help text wrapping from 80 to 100 chars for web docs (#1786)
The web docs content area fits ~101 monospace characters on desktop. The
previous 80-char wrap caused unnecessary line breaks in option
descriptions — e.g., enum variant descriptions wrapping mid-phrase like
"Stage everything: untracked files + unstaged tracked / changes".

Widens the two `help_reference()` call sites in `src/help.rs` from
`Some(80)` to `Some(100)`. Also fixes a pre-existing issue where clap's
line wrapping would break bold (`<b>`) spans across lines —
`ensure_line_resets` now tracks active SGR styles and re-opens them on
continuation lines.

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

---------

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

## Approach

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

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

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

## Key files

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <w@worktrunk.dev>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-03-28 12:18:48 -07:00
worktrunk-bot 82d02239f3 fix: correct --full example text and add removable worktree to docs (#1775) 2026-03-27 16:14:53 -07:00