Commit Graph

1008 Commits

Author SHA1 Message Date
Worktrunk Bot d80cc80ebd feat(config): flag stale default-branch cache in wt config state (#3471) (#3478) 2026-07-15 02:18:55 -07:00
Maximilian Roos bb4abca109 fix: anchor relative WORKTRUNK_PROJECT_CONFIG_PATH to the worktree root (#3460)
`WORKTRUNK_PROJECT_CONFIG_PATH` was returned verbatim, so a relative
value resolved against the process cwd while the default
`.config/wt.toml` is anchored to the worktree root. Running `wt` from a
subdirectory made a relative override silently no-op (or read an
unintended file). This is the config-path footgun from #3454 (the RFC
there is a separate discussion).

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

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

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

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

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

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

Thanks @indexzero for reporting in #3454.

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

> _This was written by Claude Code on behalf of max_

---------

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

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

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

## What a reviewer needs

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

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

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

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

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

## Testing

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:35:28 -07:00
Worktrunk Bot 9b2cbc6a8c docs(llm-commits): fix 404 Claude Code docs link (#3399)
## Problem

The `lint` CI job (its `lychee-system` link-check step) fails on the
default branch:

```
[404] https://docs.anthropic.com/en/docs/build-with-claude/claude-code | Rejected status code: 404 Not Found | Followed 2 redirects: --[301]--> platform.claude.com/docs/en/docs/build-with-claude/claude-code --[307]--> platform.claude.com/docs/en/build-with-claude/claude-code
```

Anthropic migrated its docs and the old `build-with-claude/claude-code`
path now redirects to a dead `platform.claude.com` URL. The link was
green when it last ran on `main`; the upstream migration broke it since,
so the next push to the default branch fails `lint`.

## Solution

Point the link at
`https://docs.anthropic.com/en/docs/claude-code/overview` (verified
`200`), which is the same URL form already used elsewhere in the repo.
The link lives in `docs/content/llm-commits.md` (the primary source for
this non-command doc); `skills/worktrunk/reference/llm-commits.md` is
the generated mirror, regenerated via `test_docs_are_in_sync`.

## Testing

- `curl -sIL https://docs.anthropic.com/en/docs/claude-code/overview` →
`200`.
- `cargo test --test integration test_docs_are_in_sync` passes (source
and skill mirror in sync).
- No `build-with-claude/claude-code` occurrences remain under `docs/` or
`skills/`.

---
Separated from the [PTY-flake CI fix
(#3398)](https://github.com/max-sixty/worktrunk/pull/3398) to keep each
concern atomic. Surfaced while fixing [run
29053905721](https://github.com/max-sixty/worktrunk/actions/runs/29053905721).

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-09 19:42:57 -07:00
Worktrunk Bot f593438e7c feat(remove): add experimental --reap to kill worktree processes (#3396)
Implements the design agreed in #3365: an opt-in, experimental flag on
`wt remove` that reaps processes left running in the worktree.

## The name

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

## What it does

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

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

## Data-safety posture

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

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

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

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

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

## Tests

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

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

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

Closes #3365.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-09 15:13:46 -07:00
Worktrunk Bot 29fa13dc95 docs(code-signing): add SignPath Foundation code-signing policy page (#3366)
Addresses the first, gating step of option #1 (SignPath Foundation) from
#3355 — the fix for the `Trojan:Win32/Wacatac.B!ml` false positive on
the unsigned Windows binary.

## What this PR does

Adds a **code signing policy** page at
[worktrunk.dev/code-signing/](https://worktrunk.dev/code-signing/). A
published policy page is a hard eligibility requirement for the
[SignPath Foundation](https://signpath.org/terms.html) free OSS
code-signing program, so it's the natural thing to land first — the
application can't proceed without it. The page documents:

- **Certificate provenance** — the required attribution ("Free code
signing provided by SignPath.io, certificate by SignPath Foundation")
and the HSM-held key.
- **What's signed** — `git-wt.exe` in the Windows release archive /
winget; nothing else.
- **The build+sign pipeline** — tag-triggered, cargo-dist on GitHub
runners, SignPath action for signing, all reproducible from public
source.
- **Project roles** (Author / Reviewer / Approver), **per-release manual
approval**, and the **no-telemetry** privacy stance SignPath asks
projects to state.

The generated skill mirror
(`skills/worktrunk/reference/code-signing.md`) and
`docs/static/llms.txt` entry are produced by `test_docs_are_in_sync` —
not hand-edited.

> **Roster check:** the roles table lists @max-sixty as
Author/Reviewer/Approver. Adjust if anyone else should hold signing
authority before this is submitted to SignPath.

## What this PR deliberately leaves out

The `release.yaml` signing job and the SignPath account are a
**follow-up**, for two reasons:

1. **The concrete slugs don't exist yet.** The SignPath action needs
`organization-id`, `project-slug`, and `signing-policy-slug` — all
issued *after* the project is registered and approved.
2. **cargo-dist doesn't sign natively, and the wiring is
checksum-sensitive.** cargo-dist generates a per-artifact `.sha256` in
the *local build job*, alongside the `.zip`. Signing the binary
afterward means the signed `.zip` no longer matches its checksum, so a
correct job has to sign → re-zip → **regenerate the `.sha256`** →
overwrite the artifact before the `host` job uploads it. I don't want to
ship that against a live release pipeline without being able to run it
end-to-end (it only runs on a version-tag push), so it belongs in a
follow-up gated behind the real SignPath config.

I've laid out the exact maintainer steps and the proposed job shape in a
comment on #3355.

## Verification

- `cargo test --test integration test_docs_are_in_sync` — green
(regenerates + validates the skill mirror, llms.txt, and Zola link
transformation).
- zola build + lychee link check run in CI (`check-docs`); neither tool
is installed in the tend sandbox.

Closes nothing on its own — #3355 stays open until signing is live.

## Follow-up commits on this branch

- **`docs/static/code-signing.md` companion symlink** →
`skills/worktrunk/reference/code-signing.md`, matching every existing
page, so the `worktrunk.dev/code-signing.md` entry in `llms.txt`
resolves once deployed. Also swaps a dead
`opensource.axo.dev/cargo-dist` link for the repo's
`axodotdev.github.io/cargo-dist` convention.
- **`.config/lychee.toml` exclusion** for the self-referential
`worktrunk.dev/<page>.md` links in `llms.txt`. Those point at the live
site, so a brand-new page's entry always 404s in the link check until
the site is deployed — a bootstrap gap every new-docs-page PR hits.
Their existence is already guaranteed by `test_docs_are_in_sync`, and
prose/template links use the trailing-slash form (`worktrunk.dev/faq/`),
so the exclusion is scoped to the `llms.txt` index entries only.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <worktrunk-bot@users.noreply.github.com>
2026-07-09 12:13:47 -07:00
Worktrunk Bot 512a94de2f feat(plugin): ship Codex-native activity hooks (#3364) 2026-07-06 23:05:15 -07:00
Ofek Lev 93edf90922 Correct Windows guidance on system settings (#3372) 2026-07-06 23:03:46 -07:00
Maximilian Roos 9edf0aa6b3 feat(approvals): add wt config approvals list and clear --stale (#3380)
## What

Two additions to `wt config approvals`:

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

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

## Example

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

UNAPPROVED
❯ post-start dev:
 ┃ npm run dev

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

## Notes

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

## Testing

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

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

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

---------

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

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

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:52:14 -07:00
Worktrunk Bot 67d2177029 docs(hook): split cwd exception bullet to match 'three cases' count (#3367) 2026-07-05 06:51:22 -07:00
Maximilian Roos e74b4241bf Consolidate -vv notifications onto diagnostic.md, led by the performance profile (#3329)
`-vv` runs write log files to `.git/wt/logs/`. Previously the stderr
output was noisy — a 5-path startup gutter, then the performance profile
and the diagnostic bundle each announced separately at exit, with
`profile.txt` / `diagnostic.md` named twice. This consolidates it.

## What changed

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

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

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

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

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

## Testing

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

> _This was written by Claude Code on behalf of max_

---------

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

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

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

## The three changes

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

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

## Benchmark

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

## Reviewer orientation

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

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:25:02 -07:00
Maximilian Roos 4388defff5 feat(list): add git.branch.* template namespace for custom columns (#3319)
Adds a `{{ git.branch.* }}` template namespace for `wt list` custom
columns, parallel to `{{ vars.* }}`. It surfaces a branch's own git
config under `branch.<name>.*` — both convention keys you set yourself
(`branch.<name>.jira`) and the git-native `branch.<name>.description` —
without re-storing the values through `wt config state vars set`. This
is the gap left open in #3258: `vars.*` only reads worktrunk's
`worktrunk.state.<branch>.vars.*` namespace, so keys a user already
keeps in git config were invisible to the list.

## Before / after

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

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

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

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

## Design

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

## Testing

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

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

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:19:08 -07:00
Maximilian Roos fec7edc72c feat(list): force a listed column on past the --full preset gate (#3295)
## Problem

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

## Solution

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

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

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

### Before / after

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

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

### Picker consistency

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

## Testing

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

## Docs

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

> _This was written by Claude Code on behalf of max_

---------

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

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

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

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

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

## What a reviewer needs

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

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

## Testing

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

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:59:31 -07:00
Worktrunk Bot d5be156092 perf(list): plan background tasks from the columns being rendered (#3274)
## Problem

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

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

## Solution

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

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

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

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

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

## Testing

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

Closes #3133

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-06-28 12:05:24 -07:00
Maximilian Roos a0f07ac6d7 fix(list): diff branches against the default branch's upstream tip (#3280)
## Problem

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

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

## Root cause

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

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

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

## Fix

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

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

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

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

### Deliberately unchanged

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

### Side effect

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

## Performance

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

## Tests

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

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

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

> _This was written by Claude Code on behalf of max_

---------

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

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

## What changes for the user

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

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

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

## The cursor fix (last commit)

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

## Reviewing

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

## Testing

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

> _This was written by Claude Code on behalf of max_

---------

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

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

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

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

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

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:54:24 -07:00
Maximilian Roos a725a2d786 Unify wt switch and --prs: differ only by the extra PR rows (#3252)
Full filter parity (branch, path, PR number/title/author) across worktree and --prs rows via a shared token builder; --prs dedups already-shown branches; worktree match text folds in PR data live as the forge fetch lands. Author plumbed through both forges.
2026-06-25 17:55:54 -07:00
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 de97744718 feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).

`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.

Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
Maximilian Roos 2f0ee196e6 statusline: color the rate-limit pace notice by expected-lockout severity (#3229)
The statusline pace segment (the rate-limit warning, e.g.
`1.4×(10am–3pm)`) was a flat yellow whenever it showed. This grades its
color by how bad the projected hit actually is, deepening dim →
dim-yellow → yellow.

## Two axes, separated

The segment already gated its appearance on **confidence** — `P(over)`,
the Bayesian probability of crossing the cap before the window resets.
But `P(over)` saturates near 1, so it can't also grade *severity*: a
near-certain cross in the last hour and one halfway through the week
both read ≈1.

Color is now driven by a second metric, **`expected_lockout`**: the
expected fraction of the window that will be spent throttled, integrated
over the posterior on the consumption rate (7-point Gauss–Hermite). It
keeps discriminating after `P(over)` has flat-lined, and degrades to ~0
on thin early-window bursts — so a 5× burst on Monday doesn't light up
the segment. The displayed number stays the raw `u/t` pace; only the
color carries the Bayesian severity.

## Per-window thresholds from duration, not magic numbers

The same lockout *fraction* costs very different real time across
windows: 0.30 of the 5-hour window is 90 min, 0.30 of the weekly window
is ~2 days. So `colorize_pace` compares a **duration-weighted cost** `=
lockout · √(window / 5h)` against one shared cutoff pair, rather than
two hand-tuned per-window pairs. The 5-hour window keeps its existing
calibration (weight 1); the weekly window is sensitized ~5.8×,
escalating on real time forfeited. Duration drives this (known exactly,
33.6× apart) — not σ, which is only 1.17× apart and already folded into
the posterior integral.

## Reviewing

Start at `src/commands/statusline.rs`: `expected_lockout` (the severity
metric + its module-header rationale), `lockout_cost` (the √-duration
weighting), and `colorize_pace`. The `-vv` trace now logs `lockout` and
the weighted `cost` for future threshold tuning.

Well-covered: the metric (bounds/monotonicity), the color tiers, the
cross-window weighting, and the refactor's behavioral equivalence of
`p_over` are all unit-tested; the 5-hour window is unchanged (snapshots
confirm). The threshold anchors (dim-yellow 0.10, yellow 0.30, exponent
0.5) are feel-calibrated and meant to be tuned from real traces.

## Note: included an unrelated CI fix

Merging main surfaced a pre-existing failure in `test (linux)`:
`switch_picker_alt_l_ignored_list.snap` was left stale by the #3226
merge race (it still rendered the old `Age` column while every sibling
picker snapshot already shows `Remote⇅`). Main pushes don't run the full
suite, so it slipped in and blocks any PR that merges with main. This PR
regenerates that one snapshot — it's unrelated to the statusline change
but required to get CI green.

> _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 22:57:35 -07:00
Maximilian Roos 8f17cb4a91 test(switch): run the interactive picker tests on Windows (#3221)
PR #3217 made `wt switch`'s interactive picker run on Windows, but its
tests stayed `#[cfg(unix)]` — so the Windows CI leg proved the picker
*compiled* without ever exercising its runtime. This ungates them so
Windows CI drives the real thing.

**What's ungated** (the test infra — `mock_commands`, the ConPTY
handling in `tests/common/pty.rs` — was already cross-platform, so this
is largely a gate flip):

- `switch_picker_dry_run.rs` — the headless dry-run pipeline (collect →
render → preview cache → summary spawn → warning drain).
- The `--prs` dry-run tests in `switch.rs` — forge fetch + row render
via the cross-platform mock `gh`/`glab`.
- The two requires-tty snapshots — the no-TTY bail is uniform across
platforms.
- `switch_picker.rs` — the PTY/vt100 TUI snapshot tests.

**Test-quality fix folded in:** the four `--prs` dry-run tests asserted
only `output.status.success()`, but the fetch path is fail-soft (any
error → stashed warning → exit 0). On a platform where the mock didn't
resolve they would pass vacuously. They now assert the fetched row
actually rendered (a `pr:42`/`mr:7` preview-cache entry, or the
empty-list warning).

**Also:** `/bin/cat` → `cat` in the summary test (resolves via the
platform shell on Unix and Git Bash alike); removed the stale "picker is
Unix-only" line that #3217 left in the `wt switch` docs (and regenerated
the help snapshot).

**Windows results (first full run):** of the ~50 ungated tests, 46
passed on Windows on the first run — including every PTY/vt100 snapshot
test (skim renders byte-identically under ConPTY), the accept-path drain
tests, and the `cat`-via-Git-Bash summary test. Two issues surfaced and
are handled here:

- The three interactive `--prs` picker tests failed because the test
helper `mock_forge_env` joined mock-bin onto `PATH` with a hardcoded `:`
separator — malformed on Windows (`;`), so the mock `gh.exe`/`glab.exe`
was never found. Fixed to use `std::env::join_paths` (OS-aware),
matching the dry-run path's helper.
- `test_switch_picker_pre_switch_hook_requires_approval` exits 1 instead
of 0 on Windows: declining the hook returns `Ok` on both platforms, so
the failure is in the interactive approval prompt's stdin handoff after
skim releases the ConPTY. Not reproducible locally —
`#[cfg_attr(windows, ignore)]` with a TODO until a Windows box can debug
it (still compiles on Windows, runs on Unix). This is a genuine
picker-on-Windows gap surfaced by this work, tracked for follow-up.

> _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 21:45:58 -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 5ce076c22c fix(step): apply -C to the tethered command; tether the docs server (#3207)
Make `wt step tether` honor the global `-C` flag as the spawned command's working directory (consistent with `run_custom`), and use it to tether this repo's `zola serve` docs preview so it is reaped whenever the worktree is removed — replacing the fragile [post-remove] port-kill hook that leaked ~83 zola processes.
2026-06-24 19:45:05 -07:00
Maximilian Roos c70eedf152 feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows

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

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

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

### Where to look

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

### Testing

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

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

> _This was written by Claude Code on behalf of max_

---------

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:41:52 -07:00
Maximilian Roos b3e1fc06b4 feat(switch): log + comments preview tabs for --prs picker rows (#3169)
Adds background-loaded `log` and `comments` preview tabs to `wt switch --prs` picker rows, with a width-aware tab bar that compacts to fit the 7th tab. The log tab uses a local `git log` when the head commit is present, else a forge-fetched list.
2026-06-23 19:10:15 -07:00
Maximilian Roos 9425a22bd0 Add wt config state logs profile performance profiler (#3184)
## `wt config state logs profile`

A new subcommand that turns a `-vv` trace into a performance profile,
answering the three questions in `benches/CLAUDE.md` without leaving the
terminal: where time went, how parallel the run was, and where work was
wasted.

`-vv` already writes `[wt-trace]` records to `.git/wt/logs/trace.log`;
this parses them and renders:

- **BY COMMAND TYPE / SLOWEST CALLS** — subprocess time grouped by
command shape (`git status`, `gh pr list`), plus the slowest individual
jobs.
- **parallelism / peak concurrency** — Σ(subprocess time) ÷ wall span,
and the most subprocesses in flight at once.
- **CACHE** — commands re-run within the same context (a cache miss that
should have hit).
- **KEY INTERVALS / PHASES** — for a `wt list` or picker capture,
derived latencies (time to skeleton, time to first result) and a
collect-milestone timeline. These come from the
`worktrunk::trace::instant(…)` milestones, so they populate only for
those commands; every other command still gets the sections above.

`--format=json` serializes the same struct (durations as `*_us`
integers) for scripting, so the text and JSON views can't drift. The
`diagnostic.md` bug-report bundle now inlines a rendered profile beside
the raw trace, so any `-vv` report involving slowness shows where time
went at a glance.

### Navigating the diff

- `src/trace/profile.rs` — the analysis (`Profile` /
`CacheReport::from_entries`, pure data over `&[TraceEntry]`) and the
text renderer; the `Serialize` impl is the single canonical JSON source.
- `src/cli/config.rs`, `src/commands/config/state.rs` — CLI surface and
`handle_logs_profile` (reads a path arg, `-` for stdin, or the default
`.git/wt/logs/trace.log`).
- `src/diagnostic.rs` — inlines the rendered profile into the bundle.
- `tests/helpers/wt-perf/src/main.rs` — reuses the shared `CacheReport`.

### Testing

Well covered: the renderer across full / minimal /
collect-without-skeleton / truncation variants, the JSON shape, and the
CLI end-to-end (path, stdin, default, missing-file, no-records,
not-in-a-repo). The large text outputs are file snapshots, and an
end-to-end `wt -vv list` test guards the milestone strings against
drift. The diagnostic profile section is presence-checked (placeholdered
in the snapshot, since its timing is non-deterministic) with the
rendering logic unit-tested deterministically.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:30:15 -07:00
Zexin Yuan 22484860e8 feat(completion): mirror wrapped built-in for alias arg completion (#3172)
An alias that forwards `{{ args }}` to a wt command — e.g. `co = "wt
switch {{ args }}"` or `cm = "wt step commit {{ args }}"` — now inherits
that command's argument and flag completion instead of the generic
`--dry-run`/`--yes`/`--var` stub. Alias name completion already worked;
this adds argument and flag completion.

Detection: the single command that forwards `{{ args }}` (via
`template_references_var` — minijinja, not a substring, and scoped per
command rather than the cross-command union), a leading `wt`, then a
tree-walk `find_subcommand` that lands on a leaf subcommand. The leaf
`Command` is cloned and renamed to the alias (clap `Command`/`Arg`/
`Extensions` and `ArgValueCompleter` are all `Clone`; the completer is
`Arc`-backed, so the live git query survives the clone). Bare
dispatchers (`wt step {{ args }}`), multiple `{{ args }}` forwarders,
and aliases that do not forward args fall back to the generic stub.

Mirroring runs at the top level only; `wt step <alias>` keeps the
generic stub.
2026-06-23 09:38:50 -07:00
Worktrunk Bot 16deb5e6c4 fix: use --safe-mode for Claude commit.generation to preserve apiKeyHelper auth (#3170) 2026-06-23 00:22:25 -07:00
Maximilian Roos 81f9260a46 feat(switch): browse open PRs/MRs in the interactive picker (#3128)
Adds `wt switch --prs`: browse the repository's open PRs (GitHub) / MRs (GitLab) in the interactive picker, with a live CI column (same data as `wt list --full`), a `pr` preview tab showing the markdown-rendered description, and `#`-gutter-sigil filtering. Selection routes through the existing `pr:`/`mr:` fetch-and-switch path, so there's no new switch logic.
2026-06-22 16:34:04 -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 0fc757f08d feat(list): add [list] columns to select and order built-in columns (#3141)
Adds `[list] columns` — an ordered selection of which columns `wt list`
renders. A non-empty list is exhaustive: only the listed columns appear,
in the given order (a subset and/or reorder). Both built-in columns and
`[list.custom-columns]` headers are selectable in one list. This is the
follow-up to the merged `--config-set` work (#3138): a reduced "fast"
view is `wt --config-set 'list.columns=[…]' list`, or an alias over it.

## Usage

`columns` takes a TOML array in config files or `--config-set`:

```toml
[list]
columns = ["branch", "status", "ci", "path"]
```

Built-in names: `branch`, `status`, `working-diff`, `ahead-behind`,
`branch-diff`, `summary`, `upstream`, `ci`, `path`, `url`, `commit`,
`age`, `message`. A custom column is named by its
`[list.custom-columns]` header, so a selection can mix both (`columns =
["branch", "Ticket", "ci"]`); a built-in wins over a custom header that
collides with its name.

When `columns` is set it is exhaustive — a custom column omitted from a
non-empty list is hidden. Omit `columns` entirely to keep the default
set, where custom columns append automatically.

`WORKTRUNK__LIST__COLUMNS` is deferred for now: the env overlay only
delivers scalars, so the env var is rejected with a warning and ignored
rather than silently dropped. An inline TODO plans to add it later by
parsing the value as TOML (matching the array form), not by splitting on
commas.

## Design

- **Drop-priority threading by filter, not renumber:** selection filters
the `COLUMN_SPECS` candidate list and overrides only the display sort;
`base_priority` is left untouched, so the Summary drop-loop arithmetic
and the `EMPTY_PENALTY` tiers stay correct (the allocator sorts by
priority, never indexes it). Reorder is display-only — narrow-terminal
drops follow the built-in importance order.
- **Validation at the `wt list` edge** (`parse_selected_columns`),
mirroring `[list.custom-columns]`, because `ColumnKind` lives in the bin
crate and is unreachable from the config crate. Unknown/duplicate names
error with the valid-name list; the picker degrades, foreground aborts.
- Applies to `wt list` and the `wt switch` picker (cosmetic only — the
picker fuzzy-searches a separate field, so hiding Branch never breaks
search). Statusline and `--format json` are unaffected. Gated columns
(`ci` needs `--full`, `summary` needs `[commit.generation]`) stay
hidden; custom columns append when no selection is set.

## Key files

- `src/commands/list/columns.rs` — built-in/custom name ↔ `ColumnKind`,
`parse_selected_columns`, completeness test
- `src/commands/list/layout.rs` — `ColumnSelection`, candidate filter
(built-in + custom), display sort
- `src/config/user/sections.rs` — `columns` field (plain `Vec<String>`),
merge (replace-wholesale)
- `src/commands/list/collect/mod.rs` — parse + thread the selection

Supersedes #3065 (the earlier `[list.columns]`-as-bool-map design).

## Testing

Unit tests (parser incl. built-in/custom mix and collision shadowing,
completeness guard over `COLUMN_SPECS`, layout cases incl.
gated-column-stays-hidden, custom include/hide, and
drop-by-base-priority), integration tests (config file, custom-column
select, `--config-set`, unknown-name error, env-var-not-yet-supported),
plus regenerated config-doc mirrors and help snapshots. Full pre-merge
gate green: 4051 tests, clippy, fmt, doc, doctest, lychee.

> _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 11:36:15 -07:00
Maximilian Roos c5c2742058 feat(switch): make the picker filter text match the gutter sigil (#3143)
Typing into the `wt switch` picker filtered only on branch name + path, so typing a gutter sigil like `+` matched nothing. Each row now folds its gutter glyph into the fuzzy-search text via a canonical `ItemKind::gutter_glyph` (the single source shared with the rendered Gutter column, replacing `BranchScope::gutter_sigil`), so `+` filters to linked worktrees and `@` to the current worktree.

The glyph is a skeleton-time fact (`is_current`/`is_main` set at construction, `BranchScope` structural), so folding it in keeps fuzzy ranks stable across the picker's progressive column updates, and the rendered Gutter column stays byte-identical.

`^` and `|` collide with skim's prefix-anchor and OR query operators (so `^` matches everything and `|` nothing), and `/` over-matches because every path contains it; the picker help documents which sigils filter cleanly, and an engine-level test pins the operator semantics against skim 4.8's default stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:36:11 -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
Worktrunk Bot 9147c0e46d docs(skill): frame hook approvals as user consent, stop advocating --yes (#3146) 2026-06-20 13:40:36 -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
Maximilian Roos 063e3da50d docs: wrap command-only terminal blocks; fix blank-line prompts (#3126)
The `wt list` JSON-output section (and every command-recipe block on the
docs site) rendered badly: the blank lines between recipes showed as
stray bare `$` prompts, and long `jq` pipelines overflowed the block's
right edge — scrolling out of view behind macOS's hidden overlay
scrollbar, so the tail of a command was simply invisible.

The recipe content in `src/cli/mod.rs` is clean Markdown; the defects
were entirely in the docs rendering layer, so the fix lives there and
leaves the auto-generated pages and the CLI source untouched.

- `docs/templates/shortcodes/terminal.html` — empty `|||` segments now
emit a real blank line instead of a `$ ` prompt, and command-only blocks
(no fixed-width output body) get a `terminal--commands` class.
- `docs/sass/custom.scss` — `.terminal--commands code { white-space:
pre-wrap }` wraps long pipelines at spaces, falling back to horizontal
scroll only for a single unbreakable token. Fixed-width tables and ASCII
output are untagged and keep their existing no-wrap + horizontal-scroll
behavior.

Verified against a local Zola build on `/list/` and `/tips-patterns/`:
command blocks wrap with 0px overflow, show no stray prompts, and keep
their group spacing; the three `wt list` tables are unaffected. No
`white-space` override exists in the narrow-width media queries, so
wrapping holds on mobile.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:14:55 -07:00