Commit Graph

227 Commits

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

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

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

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

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

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

<details><summary>Test</summary>

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

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

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

</details>

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-15 08:00:04 -07:00
Maximilian Roos 92dfb686bb feat(approvals): let wt config approvals add --yes record approvals without a TTY (#3819)
`wt config approvals add` refused every non-interactive run — even with
`--yes`, whose hint then suggested the flag already passed — so there
was no way to pre-approve a project's commands unattended. An
orchestrator (tend's Codex Cloud container was the motivating case) had
to hand-write `approvals.toml` from `wt config approvals list
--format=json` output, a third-party reimplementation of `add` that
breaks whenever the schema changes. The `wt config approvals` docs
already promised "`--yes` to bypass prompts in CI" and described `stale`
entries as "what `--yes` would silently re-approve"; behavior now
matches them.

The two `--yes` meanings stay distinct: on a command that runs project
commands it grants consent for that run alone and records nothing
(unchanged), while on `add` — whose product is the record — it lists
what it trusts and writes it. `add` no longer routes through
`approve_command_batch` (the execution gate) for this: it prompts or
announces, then saves itself, which also makes a failed `approvals.toml`
write fail the command instead of warning behind a `✓ saved` line and
exit 0 — an orchestrator reading only the exit code would otherwise walk
into the prompt it just paid to avoid.

The non-interactive hint's pre-approval suggestion now carries `--yes`
(`run wt config approvals add --yes`), since a hint reached in CI must
name a command that runs there. Per the existing `list --format=json`
docs, `add --yes` re-approves templates edited since an earlier approval
without comment; the `add` help now says so and points at the `stale`
field for reading them first, and the worktrunk skill's escalation rule
tells agents not to reach for it on a user's behalf.

> _This was written by Claude Code on behalf of max-sixty_
2026-08-14 02:39:29 -07:00
Caleb Cox aa9d8c43df feat: add remote_repo variable (#3745)
Add a `remote_repo` variable that returns the repo name from the remote
URL. Unlike `repo`, it stays consistent even if the clone was renamed.

Feel free to reject, or suggest other names for the variable. But this
change would improve my workflow. I hope you don't mind my submitting a
PR before opening an issue. Thanks for an amazing developer tool!

AI Disclosure 🤖: I used Claude Code to generate the changes, but
reviewed every line and made adjustments.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-14 00:48:54 -07:00
Worktrunk Bot 96c6c846f7 fix(shell): register completions under the --cmd name, not clap's (#3817)
## Problem

`wt config shell init <shell> --cmd <name>` renames the shell wrapper
and its lazy completion loader, but the registration that loader evals
comes from clap, which derives every identifier in it from its own
compile-time `Command` name (`wt`) — not from `argv[0]` and not from
`--cmd`. The two halves never agreed:

```console
$ wt config shell init zsh --cmd wot | grep _clap
        if ! (( $+functions[_clap_dynamic_completer_wot] )); then
        _clap_dynamic_completer_wot "$@"

$ COMPLETE=zsh wt | grep -oE '_clap_dynamic_completer_[a-z_]*' | sort -u
_clap_dynamic_completer_wt
```

Nothing completed, and because the guard never became true the
completion script was regenerated and re-evaluated on *every* TAB. Same
shape in bash (`_clap_complete_*`); PowerShell emitted
`Register-ArgumentCompleter -Native -CommandName wt`, so the `--cmd`
name was never registered at all. The documented `--cmd=git-wt` case
(the Windows Terminal conflict) was broken too — including for a binary
genuinely installed under that name, since clap's name comes from the
declaration rather than `argv[0]`.

There is a second, sharper edge: zsh's registration ends with `compdef
<completer> <cmd>`, so the first TAB on `wot` also bound worktrunk's
completer to plain `wt` — handing completions to the *other* `wt` that
`--cmd` exists to step around.

fish and nushell were unaffected. Both register a completer that shells
out to the binary rather than depending on a clap-emitted identifier, so
the reporter's "unverified" row for fish is a pass.

## Solution

The bash, zsh, and PowerShell loaders now pass the name they bind in
`WORKTRUNK_COMPLETE_NAME`, and `registration_name()` in
`src/completion.rs` emits the registration under that name (validated
through the same `validate_shell_command_name` guard `--cmd` uses, since
the value lands verbatim in generated shell code). The fallback is
`binary_name()`, which covers a binary installed as `git-wt` and invoked
directly. The templates apply clap's own `-` → `_` escaping to the
function they call, so `--cmd git-wt` guards on `_clap_complete_git_wt`
rather than the invalid `_clap_complete_git-wt`.

That fixes all four shells and the stray `compdef` in one place, rather
than pinning the templates to clap's internal naming:

```console
$ WORKTRUNK_COMPLETE_NAME=wot COMPLETE=zsh wt | grep -oE '_clap_dynamic_completer_[a-z_]*|compdef .*' | sort -u
_clap_dynamic_completer_wot
compdef _clap_dynamic_completer_wot wot
```

## Testing

Two reproduction tests in `tests/integration_tests/completion.rs`, both
failing before the change:

- `test_init_custom_cmd_defines_clap_completer_in_bash` drives the whole
chain through a real bash — generate the init script with `--cmd`, call
the loader it defines, then assert clap's completer function exists
afterwards. Printed `MISSING` before, `DEFINED` after. Cases for `wot`
and `git-wt`.
- `test_completion_registration_uses_shell_integration_cmd_name` covers
zsh and PowerShell, which CI can't drive: the identifier the init script
references must be the one the registration defines, and the `compdef` /
`-CommandName` target must be the `--cmd` name.

`cargo test --lib --bins` and `cargo test --test integration` are
otherwise green (one unrelated failure locally,
`test_copy_ignored_preserves_file_executable_permissions`, from this
sandbox's `umask 0002`), and `cargo clippy --all-targets --all-features`
/ `cargo fmt --check` are clean.

---
Closes #3816 — automated triage

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-13 16:44:02 -07:00
Worktrunk Bot bdce107d91 fix(config): rank env vars and --config-set above project entries (#3790)
Fixes #3788.

Layer and specificity were separate steps. `load_with_warnings`
flattened system config → user config → `WORKTRUNK_*` env vars →
`--config-set` into one document, and the accessors then resolved
specificity on that document, so a `[projects."<id>"]` entry answered
for the global key of the same name whichever layer set it.
`WORKTRUNK_WORKTREE_PATH` could therefore not override a project's
`worktree-path`, and a global `--config-set` hit the same wall.

Per @max-sixty in the issue thread — "env vars should indeed take
precedence over the user project config, we should fix this throughout"
— the two invocation layers now cross the axes: they're typed for one
run, so they outrank a project entry as well as the global key. Load
applies them at both scopes (`apply_invocation_layer_over_projects`, the
last step before `finalize`): whatever the layer set is dropped from
every project entry, leaving the global key it also set to answer for
it.

Two kinds of key are held back:

- **Keys the layer restates under `projects."<name>"`** — `--config-set
'projects."github.com/owner/repo".worktree-path = …'` is both the
highest layer *and* the most specific key, so it still wins over the
same layer's global key.
- **Composing keys** — hooks, aliases, and `step.copy-ignored.exclude` —
whose project-scoped values append to the global ones rather than
replacing them. Both already apply, so an env-set hook was never
outranked, and dropping the project's copy would silently stop it
running. Hook names come from `HooksConfig`'s schema, so a new hook
can't be forgotten.

Two sections have to go as a unit rather than leaf by leaf.
`[commit.generation]`'s mutually exclusive pairs: `template` and
`template-file` clear one another in `merge_with` *and* are rejected
together by `validate`, so overriding either has to displace both at
project scope — otherwise the project's partner would still win the
merge. `exclusive_sibling` names those pairs. And
`[list.custom-columns]`, which `ListConfig::merge_with` extends per
whole column, so a partial removal leaves the project's column replacing
the global one anyway — and `ListColumnConfig::template` is required, so
it can also strand a column that no longer deserializes.
`is_atomic_section` names that table.

Both are enumerations, so the pass degrades as a unit behind them: the
removals land on a candidate, kept only if it still deserializes and
validates. That is the guarantee the env and `--config-set` layers
already have, and without it the next required field would answer a
stranded leaf with `UserConfig::default()` — costing the user their
whole config for that invocation rather than one project entry's
precedence.

The precedence table now reads:

| Source of `worktree-path` | Loses to |
|---|---|
| `--config-set 'worktree-path = …'` | — |
| `WORKTRUNK_WORKTREE_PATH` | `--config-set` |
| `[projects."github.com/owner/repo"]` in a config file | either
invocation layer |
| global `worktree-path` in a config file | all of the above |

## Docs

The help text had no precedence section at all — the gap that made this
read as a bug — so this adds one under **Environment variables**, plus a
pointer from **User project-specific settings**. That supersedes #3789,
which documented the old behavior; I'll close it in favour of this.

## Testing

Nine unit tests in `src/config/user/tests.rs` cover the table-level rule
(both layers, pattern entries, restated project-scoped overrides,
untouched sibling keys, composing keys, the exclusive pair, the atomic
custom column, a rolled-back layer, and the no-override no-op), and
`test_switch_create_invocation_layers_outrank_project_worktree_path`
proves it end-to-end — a real process is the only thing that reads
`WORKTRUNK_WORKTREE_PATH` off the environment. That test keeps a control
showing the project entry still beats the config file's own global key,
so it can't pass by project entries having stopped applying.

The reproduction from the issue now lands where it says it should:

```console
$ WORKTRUNK_CONFIG_PATH="$tmp/wt.toml" WORKTRUNK_WORKTREE_PATH="$tmp/from-environment" \
    wt switch --create feature --no-cd --no-hooks --yes --format=json
{"action":"created","branch":"feature","path":"/tmp/tmp.jQiTAuNPFd/from-environment",…}
```

<details><summary>Local suite</summary>

`cargo test --lib --bins` and `cargo test --test integration` are green
apart from `test_copy_ignored_preserves_file_executable_permissions`,
which fails in this sandbox because its umask is `0002` (file created
`0664`, test expects `0644`) — unrelated to this change and not
reproducible on a `0022` runner. `cargo fmt --check` and `cargo clippy
--all-targets --all-features` are clean.

</details>

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-08-13 09:06:00 -07:00
Worktrunk Bot d4538f2047 fix(alias): carry the shell's cwd into alias and hook bodies (#3724)
## Problem

Since #939 / #3344, `wt switch` and `wt remove` preserve the user's
subdirectory position — from `monorepo.feature/subproject/` you land in
`monorepo/subproject/`. #3723 reports that this is lost one layer down:
with `[aliases] finish = "wt remove -y"`, `wt finish` drops you at the
primary worktree root.

The resolution reads the user's position from the wt process's own cwd
(`resolve_subdir_in_target`, called with `std::env::current_dir()`).
That answers "where is the user standing?" only for a top-level
invocation. Alias and hook bodies run with the worktree root as their
working directory, so the nested `wt` strips the source root off a cwd
that *is* the source root, gets an empty relative path, and falls back
to the destination root.

## Solution

The CD directive file already travels to exactly the children that are
allowed to move the user's shell. The shell's directory now travels with
it: `apply_cd_directive_env` sets `WORKTRUNK_SHELL_CWD` wherever the CD
file is re-added (`Cmd::stream` and the concurrent runner), and
`scrub_directive_env_vars` strips it alongside the other directive vars,
so an untrusted child neither keeps nor receives it.

`shell_exec::shell_cwd()` reads it back, preferring the inherited value
over the process cwd — which is what makes nesting compose, since each
layer forwards the shell's directory rather than its own. Three sites
ask that question and now go through it: `wt switch`, `wt remove`
(`prepare_remove_directory_change`), and `wt step relocate`, whose
existing comment already asks to behave identically to the other two.

Nothing about the working directory of alias or hook *bodies* changes —
`{{ cwd }}` is still the worktree root, per the documented contract. The
only change is what a nested `wt` believes about the user's position.

## Testing

Two integration tests in `tests/integration_tests/step_alias.rs`, both
failing before the change with the exact symptom reported:

```
CD file should preserve the subdirectory (…/repo/apps/gateway), got: "…/repo\n"
```

- `test_alias_wrapping_remove_preserves_subdir` — the reported case
(`[aliases] finish = "wt remove -y"` run from `feature/apps/gateway`).
- `test_alias_wrapping_switch_preserves_subdir` — the same for `wt
switch` inside an alias.

The existing subdirectory-preservation tests in `directives.rs`
(including the fall-back-to-root cases) still pass, as does the full
integration suite — apart from
`test_copy_ignored_preserves_file_executable_permissions`, which fails
identically on `main` in this sandbox (umask `0002`, expects `0644` gets
`0664`) and is unrelated.

The open question from the first revision is answered: the new remove
test leaves two processes with a cwd inside the worktree being removed
(the alias parent in the subdirectory, the nested `wt` at the root)
where the existing test has one, and `test (windows)` passes on it.

Review follow-ups are in 31d8b3d (pure `shell_cwd_from` plus its unit
test, `SHELL_CWD_ENV_VAR` added to the scrub-coverage test, corrected
`startup_cwd()` comment) and 2578348 (the fixtures in that unit test
derive from `temp_dir()` instead of a `cfg!(windows)` literal pair,
whose untaken arm was the last `codecov/patch` miss). One
`code-coverage` run failed on
`progressive_handler::tests::on_update_pokes_run_preview_only_when_the_visible_pane_changes`
— unrelated to this change, passing in `test (linux)` on the same commit
and in the coverage run on the parent commit — and passed on re-run.

Closes #3723

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-05 09:29:36 -07:00
Maximilian Roos 5c42c5b7d5 feat: machine-readable approval state and branch-removal outcomes (#3710)
Two of the five machine-readable-output requests NathanaelRea opened
(#3696–#3700), reviewed as a set and implemented where the gap was real.

## `wt config approvals list --format=json` (#3698)

The command already computed the four distinctions an orchestrator needs
— no commands, approved, approval-required, and stale — read-only,
without prompting or writing. It had no `--format` flag, so the only way
to learn that a non-interactive run would stop for approval was to run
the operation and catch `NotInteractive`, or to pass `--yes` and approve
whatever was there.

```json
{
  "state": "approval_required",
  "commands": [
    {"phase": "post-start", "name": "dev", "template": "npm run dev", "approved": false},
    {"phase": "pre-merge", "template": "cargo test", "approved": true}
  ],
  "stale": ["some removed command"]
}
```

`state` is what a caller branches on. `stale` stays a separate list
rather than a fourth `state`, because it co-occurs with all three — and
those are the approvals `--yes` would silently re-approve after their
command template changed, which is exactly what an orchestrator
preserving the approval model needs to see.

A flag on the existing read command rather than a new `status` verb,
matching `wt config show`, `wt config state get`, `wt config state
logs`, and `wt list`.

Closes #3698.

## `branch_outcome` on removal (#3700, partly)

`wt remove --format=json` reported the branch as one boolean, collapsing
five internal outcomes into two values:

| Internal outcome | `branch_deleted` was |
|---|---|
| `Deleted` | `true` |
| `Deferred` — handed to a detached process, result never observed |
`true` |
| `NotAttempted` — no branch, or `--no-delete-branch` | `false` |
| `Retained` — a sibling worktree has it checked out | `false` |
| `Retained` — **the CAS refused; the ref moved under us** | `false` |

The last row is the exact race #3700 asks for protection against.
Worktrunk already deletes with `git update-ref -d <ref> <oid>` and
already fails closed when the ref has moved — then reported it as the
same `false` that means "you asked me not to". And `Deferred` reported
`true` on intent.

`branch_outcome` names it instead: `deleted`, `deferred`,
`not_attempted`, `retained_unmerged`, `retained_checked_out`,
`retained_raced`, `retained_failed`. A caller that sees `retained_raced`
knows to re-read the ref and retry, which is what the guard detects it
for.

**This does not close #3700.** That issue asks for an *input* — a
caller-supplied expected OID that makes `wt` fail closed against the
orchestrator's own observation. This is an *output*. They land in the
same place on the default path, because the integration check already
refuses to delete unintegrated content, so the caller was never going to
lose commits — they just couldn't classify the refusal. Where the gap is
real is `--force-delete` / `-D`, which takes the early return in
`delete_branch_if_safe` and runs `git branch -D` with no integration
check and no CAS. If an `--expected-oid` flag lands, it has to gate that
path.

## Notes

- **Output-format break.** `branch_deleted` is replaced, not
supplemented, on `wt remove --format=json` and on `wt step prune
--format=json`'s live path. Per CLAUDE.md, output formatting is on the
flexible side of the interface line; flagging it here so the release
changelog picks it up.
- **`wt step prune --dry-run` keeps `branch_deleted`.** A dry run
predicts; it runs nothing to have an outcome. Different thing, different
name, documented as such.
- **`retained_raced` and `retained_checked_out` have no deterministic
CLI trigger.** Both come from windows between `wt`'s own fresh read and
the ref mutation, which no hook can be scheduled inside. They're covered
at the unit level (`branch_fate_from_result_mapping`,
`branch_fate_json_outcome_is_distinct_per_fate`, and
`cas_rejects_delete_when_branch_advances` in `src/git/remove.rs`, which
drives the race with a stale snapshot). The integration tests cover the
two reachable contrasts: `retained_unmerged` via a `pre-remove` hook
that commits, and `not_attempted` via `--no-delete-branch`.
- **`print_json` lives under `src/commands/list/`** and now has a third
caller from outside that module. Worth a more central home; not moved
here.

## The other three

Reviewed but not implemented:

- **#3696** — already possible. `wt --config-set 'list.json-schema = 2'
list --format=json` pins the schema per invocation above every config
layer, as does `WORKTRUNK_LIST__JSON_SCHEMA`. Answered on the issue;
what's left is a docs gap and making an out-of-range value fail rather
than degrade in JSON mode.
- **#3697** — the machine-readable error channel. A real gap and the one
policy call in the set; not started.
- **#3699** — aimed at `wt config state logs --format=json`, which is a
directory listing reconstructed from paths, under a model that
overwrites. The append-only run record it wants is `commands.jsonl`.

## Testing

`cargo run -- hook pre-merge --yes` green: 4533 tests, clippy, fmt,
doctests, rustdoc, docs sync.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:17:27 -07:00
Maximilian Roos 02a12c7f59 feat(config): match [projects."…"] keys by pattern, and carry forge there (#3701)
## Problem

Forge platform is readable only from project config (`[forge].platform`)
or a brand substring in the remote hostname. A self-hosted host carrying
none of `github`/`gitlab`/`gitea` — a GitLab at `git.company.example`, a
company git server — needs the same `[forge]` block in every
repository's `.config/wt.toml`. Closes #3678.

The user-level `[projects."…"]` table is where per-repository settings
already live without touching each repo, but its keys are exact, so
covering a host means one entry per repository.

## Solution

**Pattern keys.** A `[projects]` key containing `*` matches any run of
characters, `/` included, so one entry covers every repository on a
host, nested groups and all. `*` is the only metacharacter.

```toml
[projects."git.company.example/*"]
forge.platform = "gitlab"

[projects."git.company.example/platform/*"]
worktree-path = ".worktrees/{{ branch | sanitize }}"
```

Every matching entry applies, least- to most-specific, so a narrower key
wins where two set the same field and leaves the rest alone. A literal
key is the most specific of all; specificity is the count of non-`*`
characters. Rules and rationale: the `project_match` module docstring.

**`forge` on `[projects]`.** Same shape as the repository's own block,
carrying `platform` and `hostname`. Both describe the host rather than
the repository — which is why an SSH alias resolved through
`~/.ssh/config`, a name local to one machine, belongs in user config
rather than a repository's committed one. A repository's own `[forge]`
still wins field by field, being the more specific of the two: a
repository that sets only `platform` still takes a matching entry's
`hostname`.

**One resolver.** `wt list`, its statusline, `wt switch pr:`, and
CI-platform detection each read project config separately, so a
configured platform could resolve in one command and read `unknown` in
the next. They now share `Repository::configured_forge_platform` (and
`forge_hostname` for the API host).

## Approvals

`approved-commands` matches by the same rules, so a pattern entry
approves its commands for every repository it covers. That widening is
the user's to opt into — only a hand-written key is ever a pattern:

- `wt config approvals add` and the interactive prompt record under the
exact project identifier, so approving in one repository never reaches
another. An identifier that itself contains `*` (a starred remote URL or
no-remote path fallback) is refused outright — persisting it verbatim
would create an entry reads treat as a pattern; the interactive flow
degrades to a warning plus a per-run approval.
- `wt config approvals clear` empties only the exact entry, leaving a
pattern other repositories share intact — and both its outcomes end with
a hint naming any pattern entries still approving commands for the
project, so a surviving approval is traceable to the hand-written entry
supplying it.
- `--stale` judges only the exact entry, so one repository's config
can't revoke approvals the others rely on.

## Tests

`project_match` unit tests cover `*` spanning `/`, `.` staying literal,
specificity ordering, and the lexicographic tie-break. Config tests
cover a host-wide entry applying to nested groups, exact-over-pattern
precedence, field-by-field layering, hooks appending across both
entries, and forge platform/hostname. Forge resolution tests cover the
unbranded host, nested groups, a narrower entry winning, project config
overriding, falling through to inference, and an invalid value leaving
the host unresolved. Approvals tests cover pattern lookup plus the two
exactness guarantees above.

## Docs

`src/cli/mod.rs` (the primary source) gains "Matching several
repositories with one entry" and "Forge platform and hostname" under
user project-specific settings, plus a pointer from the project-config
forge section. Generated mirrors and `--help` snapshots regenerated.

## Review hardening

An adversarial review pass surfaced eight findings, all fixed:

- **Approval widening (moderate)**: the starred-identifier refusal
above. Previously such an approval persisted verbatim and silently
approved its commands for every repository the star matched.
- **Literal-key tie (moderate)**: a pattern whose stars all match empty
(`github.com/owner/repo*`) ties the exact key on literal count and
sorted after it, so its values won the fold. Literal keys now outrank
any pattern outright.
- **Docs vs behavior (moderate)**: the layering paragraph claimed "most
specific wins" for everything; hooks and aliases actually append across
matching entries (all run, least-specific first). Docs now say so, and
state the forge field-by-field precedence.
- **Minor**: `matches()` is a two-pointer byte glob (was a per-call
regex compile, ~0.7 ms per pattern key, a few hundred calls per `wt
list`), pinned by an exhaustive differential test against a reference
matcher; the invalid-platform diagnostics name their two possible config
homes; a root `[forge]` in user config now points at
`[projects."<id>"].forge`; docs note a host-wide key should end in `/*`;
`approve_command` delegates to `approve_commands`, unifying their dedup
predicates.

## Relationship to #3681

This is an alternative to #3681, which adds a bespoke `[forge-hosts]`
section for the same issue. Both can't land — they'd be two ways to
write one sentence. This one puts the setting in the table that already
carries per-repository user config, and the pattern keys are reusable
for the workspace-scoped ask in #3654 where repositories share a host or
namespace.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-01 23:06:18 -07:00
Maximilian Roos e1745db105 feat(list): abbreviate the table's SHA with git, not a fixed slice (#3676)
The Commit cell sliced `&head[..8]` while `--format=json`'s `short_sha`
carried git's `%h`, so one commit read `1b9f1d96` in the table and
`1b9f1d9` in JSON, and `core.abbrev` reached only the JSON. #3675 gave a
detached row's Branch cell the same slice, so the disagreement showed up
twice on one row.

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

## Column widths

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

## Latency

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

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

## Behavior change

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

## Reading the diff

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

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

> _This was written by Claude Code on behalf of max-sixty_
2026-07-30 18:33:30 -07:00
Maximilian Roos 0f2d562541 fix(forge): classify a forge by the brand in the hostname, not by DNS label (#3673)
Reverts the branded half of the exact-label classifier and deletes the
diagnostic built to explain it. `github-enterprise.acme.com`,
`mygithub.com`,
`gitlab-internal.company.com`, and the `github-personal` SSH alias
resolve to
their forge again, so CI status, `wt switch --prs`, and `repo.provider`
work
with no config.

## Why the label boundary goes

It looked like an ownership check and wasn't one. An attacker controls
their own
DNS, so `github.attacker.example` has the exact label `github` and
classified
fine; `gitlab.evil.co.uk` likewise. What the rule actually excluded was
the
self-hoster who put the brand in a hyphenated name. It failed open for
the
adversary and closed for the customer.

The residual case for it doesn't survive either. The hostname comes out
of the
user's own `.git/config`, and whoever can put a host there can put code
there
too — the trust decision happens at clone time, and by the time
worktrunk reads
the remote the user is already building from it. All the classification
decides
is which forge CLI (`gh`, `glab`, `tea`, `az`) runs against it.

So the rule is recall-first: any host carrying `github`, `gitlab`, or
`gitea`
matches, first match winning. The cost is a host that merely sounds like
a forge
getting a forge CLI run at it, which surfaces as that CLI's error rather
than as
silence — the better of the two failures, and `forge.platform` overrides
it.

## What stays

Azure DevOps keeps suffix matching on its two service domains, and for a
reason
unrelated to security: those are service domains rather than a brand in
the
host, so every real hosted instance already matches, and the on-prem
edition
carries neither string. `dev.azure.com.attacker.example` and
`evil-visualstudio.com` are outside the domains and carry no brand to
fall back
on, so they stay unclassified. Userinfo still resolves to the network
host, so
`https://github.com@attacker.example/…` is `attacker.example`.

## What goes

`LegacyForgeAlias`, `Repository::legacy_forge_alias`,
`legacy_forge_alias_diagnostic`, and its three emit sites in `wt list`,
`wt switch --prs`, and `wt config show --full`. Every host the
diagnostic fired
on now classifies, so it could only ever return `None`. A host with no
brand at
all still reaches the existing generic hint, which is the right message
there —
there is no platform to infer.

The end-to-end warning-dedup test goes with it, since no warning is
raised from
both the collect and `--prs` threads any more;
`stash_warning_preserves_order`
keeps the mechanism covered.

## Docs

`## Forge platform` in `src/cli/mod.rs` described the override as being
for SSH
aliases and self-hosted instances, which now detect on their own. It
states the
rule and scopes the override to hosts carrying no brand — a Forgejo
instance at
`forge.example.com`. Mirrors, `dev/wt.example.toml`, and the two
`config` help
snapshots regenerate from it.


---

The branch's history has a false start — the first commit widened the
diagnostic, the second deletes it in favour of relaxing classification —
plus a merge of `main` after v0.71.0 shipped. The net diff is the second
approach; it all squashes on merge.

Also supersedes
[#3672](https://github.com/max-sixty/worktrunk/pull/3672), the triage
bot's PR for the same issue — it widens the diagnostic rather than
removing the need for one, so it should be closed too.

Closes #3671

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:20:58 -07:00
Maximilian Roos 63071709af feat(config): deprecate list.task-timeout-ms (#3615)
Removes the `[list] task-timeout-ms` per-task command timeout and the
thread-local machinery behind it. The key stops having any effect
immediately; a config that still carries it loads, warns, and is
stripped by `wt config update`.

The timeout killed any git command that outlived its budget, on every
collect worker, through a thread-local that `Cmd::run` consulted on each
invocation and clamped an explicit `.timeout()` against. Progressive
rendering removed the reason for it: `wt list` and the picker paint from
local data and stream results in behind the frame, so no single git
command can hold up the first paint. What it bounded instead was
completion, which `[list] timeout-ms` already bounds directly, and the
drain falls back to a hardcoded 120s `DRAIN_TIMEOUT` whenever that is
unset (`collect/mod.rs` `drain_deadline`), so removing this cannot
introduce an unbounded wait. Both keys default to unset, so the default
path never had a per-task timeout at all.

`[list] timeout-ms`, the wall-clock budget for the whole collect phase,
stays. It is the surviving knob and the more direct expression of the
same goal.

With the thread-local gone, the two-source `min()` in `Cmd::run`
collapses to the command's own `self.timeout`, so every explicit
`.timeout()` caller keeps its bound unclamped: `PROBE_TIMEOUT` in
`git/reap.rs`, the fsmonitor stop/lsof bounds in `git/remove.rs`, the
version check in `config/show.rs`, and `REMOTE_DETECTION_TIMEOUT`.

## Deprecation

A `Structural` row in `DEPRECATION_RULES` strips the key from `[list]`
in both the section and inline forms, top-level and per-project,
following the `[switch.picker] timeout-ms` precedent (also a strip with
no equivalent key to migrate into):

```
▲ User config: list.task-timeout-ms is no longer used — list.timeout-ms bounds the collect phase
```

The env overlay (`WORKTRUNK__LIST__TASK_TIMEOUT_MS`) and `--config-set`
route through the same rule and migrate silently, since neither layer
has a file for `wt config update` to materialize. Neither errors.

## Testing

New unit tests cover detection and migration for the section, inline,
and per-project forms plus the warning text, and two cases join the
`test_warning_fires_iff_update_changes` battery that pins the
warn-iff-update-changes invariant. The three integration tests that
exercised the feature are gone; `Cmd::timeout` keeps its own coverage in
`shell_exec.rs`, so dropping the four thread-local unit tests loses
nothing for the surviving path. Verified end to end that a config
carrying the key loads, warns, and has it stripped by `wt config update`
with sibling keys intact.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:36:41 -07:00
Maximilian Roos 8865f20ab8 fix(git): bound the default-branch remote query, and make the bound bound (#3603)
Follow-up to #3596, which walled the test suite off from the network.
That PR left the same unbounded call live for real users:
`Repository::default_branch()` is the one detection helper allowed to
fall through to `git ls-remote`, and nothing limited how long it could
take *not* to answer — an unanswered SYN costs ~127 s per address on
Linux (`tcp_syn_retries=6`) and git tries each of a host's addresses in
turn, so a remote behind a dropped VPN or a dead host stalled `wt list
--full` and `wt switch` for minutes.

## `Cmd::timeout` didn't bound wall-clock

Adding a bound alone would have been decorative, which is the substance
of this PR. `run_with_timeout_impl` killed only the direct child, and a
grandchild inherits the child's stderr pipe — so a surviving one held
the write end open and `read_to_end` blocked until it exited. Measured
on a 3 s timeout over an `ls-remote` whose upload-pack sleeps 120 s:

| | elapsed |
|---|---|
| before | 120.03 s |
| after | 3.20 s |

`git-remote-https` sitting in `connect()` is exactly that shape: it
doesn't notice git died. So a timed child spawns into its own process
group and expiry tears down the group — `killpg` with TERM → KILL
escalation on Unix, `taskkill /T /F` on Windows, matching `wt step
tether`. Every existing `.timeout()` caller (the fsmonitor stop/lsof
probes, `reap.rs`, the shell probe) was latently unbounded the same way
and is fixed with it.

The isolation costs the kernel's tty broadcast: a Ctrl-C no longer
reaches a timed child, so the user waits out the remaining bound. That's
seconds, against an orphan holding a pipe for as long as its own
operation takes, once per spawn. Recorded at `run_with_timeout_impl` and
in CLAUDE.md's Signal Handling section, whose "only the current child
does" claim was no longer true.

## The cache is the other half

A timed-out query takes the local-inference fallback but is *not*
persisted to `worktrunk.default-branch`. That cache is what stops later
calls from re-detecting, so a guess made while the network was down
would otherwise become the repo's permanent answer. `detect_from_remote`
now returns a `RemoteDetection` enum so the cacheability decision is
explicit and exhaustive rather than an `Option` that loses the
distinction.

Only a timeout separates cleanly, via `ErrorKind::TimedOut`. `ls-remote`
exits 128 whether the network is down or the remote simply has no HEAD,
so telling those apart would mean reading git's error text — and
re-querying on every command is the cost the cache exists to avoid.
Those stay cached, as before.

## Reviewing

- `src/shell_exec.rs` — the process-group teardown; its docstring
carries the rationale
- `src/git/repository/config.rs` — `RemoteDetection`, the 10 s bound,
and the no-persist path
- `src/git/repository/mod.rs` — `run_command` now delegates to a
`run_command_bounded` that takes the bound

Also routes the four remaining hand-rolled git test envs
(`src/git/remove.rs`, `src/git/repository/tests.rs`,
`tests/integration_tests/bare_repository.rs`) through
`configure_git_env`, so they carry #3596's `GIT_ALLOW_PROTOCOL` deny
rather than re-deriving a subset of it. All four run local-only git
commands today, so this is completeness, not a live hole — and a full
suite run under `GIT_TRACE` confirms zero `git-remote-*`
transport-helper spawns across 4374 tests.

## Testing

Three tests, none of which touch the network: the grandchild case via
`sh -c 'sleep 30; :'` (which stops the shell `exec`ing sleep, so there
really is a grandchild), the end-to-end no-persist behavior via
`remote.origin.uploadpack` pointed at a `sleep`, and the fail-fast
unresolvable remote, which had no coverage before. The second waits out
the real 10 s bound; both hanging-remote tests are `#[cfg(unix)]` since
the vehicle needs a POSIX `sleep`, so `test (windows)` doesn't cover the
no-persist path.

> _This was written by Claude Code on behalf of max_
2026-07-25 13:47:09 -07:00
Maximilian Roos 568b6de85f docs: consolidate duplicated explanations and trim slop (#3494)
Sweep of the docs for repetition and filler, from an audit of the
hand-authored pages, the command pages' source in `src/cli/mod.rs`, and
the plugin skill. Net −574 lines; every cut either had a surviving
canonical home or restated an adjacent sentence.

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

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

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 13:44:44 -07:00
Worktrunk Bot 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
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
Maximilian Roos 9edf0aa6b3 feat(approvals): add wt config approvals list and clear --stale (#3380)
## What

Two additions to `wt config approvals`:

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

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

## Example

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

UNAPPROVED
❯ post-start dev:
 ┃ npm run dev

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

## Notes

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

## Testing

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

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

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

---------

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

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

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:52:14 -07:00
Maximilian Roos e74b4241bf Consolidate -vv notifications onto diagnostic.md, led by the performance profile (#3329)
`-vv` runs write log files to `.git/wt/logs/`. Previously the stderr
output was noisy — a 5-path startup gutter, then the performance profile
and the diagnostic bundle each announced separately at exit, with
`profile.txt` / `diagnostic.md` named twice. This consolidates it.

## What changed

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

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

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

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

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

## Testing

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

> _This was written by Claude Code on behalf of max_

---------

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

## Before / after

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

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

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

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

## Design

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

## Testing

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

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

> _This was written by Claude Code on behalf of max_

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

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

## Solution

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

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

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

### Before / after

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

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

### Picker consistency

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

## Testing

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

## Docs

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

> _This was written by Claude Code on behalf of max_

---------

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

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

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

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

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

## What a reviewer needs

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

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

## Testing

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

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:59:31 -07:00
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 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 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 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
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 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 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 68dae3a2fc docs(cli): drop redundant --format value parenthetical where clap lists them (#3112)
Follow-up to #3108. That PR made clap render `--format`'s values inline
as `[possible values: …]`, but five flags still carried a hand-written
value parenthetical in their doc comment, so the list rendered twice —
three times in `wt list statusline -h`:

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

This drops the parenthetical (→ bare `/// Output format`) from every
`--format` flag that does **not** hide possible-values, leaving clap's
inline list as the single source: `wt list statusline`, `wt step
for-each`, `wt step prune`, `wt config show`, `wt config state vars
list`.

The deliberate hiders are left untouched — they set
`hide_possible_values = true`, so their parenthetical is the *only*
value list: `wt list`, `wt config state get` (both suppress the
`claude-code` value, which is meaningless for them), and the shared
`GlobalFormatFlag` for `config state
cache`/`logs`/`hints`/`ci-status`/`marker`. The rule: if clap prints the
list, drop the hand-written one; if clap's list is suppressed, keep it.

For `wt list statusline`, `claude-code`'s non-obvious behavior stays
visible in the long-help possible-values block (`- claude-code: Claude
Code statusline mode (reads context from stdin)`) and the dedicated
"Claude Code mode" section.

This also regenerates a stale mirror: `wt step eval --format`'s
`step.md` section was out of sync with the binary (expanded block vs.
terse) because #3106 added that flag with the old `SwitchFormat` while
#3108 trimmed it — their merge order left `test_docs_are_in_sync`
failing on `main`. This PR fixes that.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:12:58 -07:00
Maximilian Roos 41d5ef7349 docs(cli): render --format help as terse inline possible-values (#3108)
Every `--format` flag — `wt step
commit`/`squash`/`rebase`/`push`/`for-each`/`copy-ignored`, `wt remove`,
`wt switch`, `wt merge`, `wt config show` — rendered a verbose `Possible
values: - text: … - json: …` block in long help and the generated doc
pages. clap auto-generates that block from the per-variant doc comments
on the shared `SwitchFormat` enum.

Dropping those variant doc comments makes clap render the terser inline
`[possible values: text, json]` instead, slimming every `--format`
flag's long help in one place. The variant names are self-describing, so
the descriptions added nothing.

The sibling `OutputFormat` enum gets the same trim for `Table`/`Json`,
but `claude-code` keeps its description — "reads context from stdin"
conveys real, non-obvious behavior the bare name doesn't. That one
surfaces in `wt list statusline --help`.

Doc mirrors (`docs/content/`, `skills/worktrunk/reference/`) and 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-17 10:49:43 -07:00
Worktrunk Bot 0d9b9b8082 feat(commit-generation): deprecate commits squash template variable (#2985) 2026-06-13 14:37:15 -07:00
Maximilian Roos c511d3fa2b feat(list): show PR/MR number in the CI column (#3041)
The CI column in `wt list --full` (and the statusline) previously showed
a single colored dot. It now shows the branch's open PR/MR reference —
`#3035` on GitHub/Gitea/Azure DevOps, `!3035` on GitLab — colored by CI
status, dimmed when stale, and hyperlinked to the PR. When no number is
available (branch workflows without a PR/MR, pre-number cache entries,
or a number wider than the allocated column), the cell shows a bare `#`
in the same colors. Fetch errors always render `⚠`, even when a number
is known — Error and Conflicts share yellow, so a yellow `#3035` would
read as a conflicted PR.

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

## The width problem

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

## Reviewer's map

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

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

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

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:36:44 -07:00
Maximilian Roos 19aa6f0a28 feat(config-state): add cache subcommand, gate clear behind confirmation (#3027)
`wt config state` had a sprawl of per-category subcommands, several of
which only really existed to be cleared. This consolidates the
regenerable caches under one `cache` subcommand, makes the destructive
`clear` confirm first, and deprecates the now-redundant subcommands
without breaking them.

## What changed

**New `cache` subcommand** — the single home for everything regenerable:
CI status, summaries, git-commands (the SHA-keyed disk caches), hints,
and the `wt switch -` target. `wt config state cache` shows them
(`--format=json` too); `cache clear` drops them all with **no prompt**,
since clearing only forces recomputation. It re-shows one-time hints and
forgets the switch-back target — both repopulate on their own.

**`clear` now confirms** — `wt config state clear` still removes
*everything*, but it also wipes hand-authored markers and vars, so it
now prompts before doing so. `--yes` skips the prompt. Non-interactive
without `--yes` declines safely (no data loss) — the conservative
direction. This is the one behavior change to watch: a script doing `wt
config state clear` without `-y` now cancels instead of wiping.

**`ci-status` / `hints` / `previous-branch` deprecated** — hidden from
`--help` and emitting a one-line stderr deprecation notice, but still
fully functional. Existing scripts keep working (the notice is
stderr-only, so stdout parsing is unaffected); new users only discover
`cache`.

## Navigating the diff

- `src/commands/config/state.rs` — the bulk. `handle_state_clear_all` is
refactored to compose per-category `clear_*_reported` helpers + the
confirmation gate; `handle_cache_clear` reuses the regenerable subset.
`cache get` and the aggregate `state get` share extracted
`render_*_section` / `*_json` helpers, so the two views render each
category identically. The module docstring documents the
regenerable-vs-authoritative split and the preserved `get`↔`clear`
parity invariant.
- `src/cli/config.rs` — the `Cache` variant + `CacheAction`, `hide =
true` on the three deprecated subcommands, and the doc/help text.
- `src/main.rs` — dispatch: the `cache` arm, the deprecation warnings
(now uniformly bolding the replacement command across all four `wt …`
deprecation messages), and threading `--yes` into `clear`.

## Testing

Well covered: new integration tests for `cache get`/`cache clear` (empty
and populated), the `clear` prompt (accept and decline paths),
authoritative-state preservation under `cache clear`, and the
deprecation warnings — plus the existing aggregate `state get`/`clear`
suite, which exercises the shared helpers. Docs regenerated and
`test_docs_are_in_sync` passes. Full pre-merge gate (tests + clippy +
fmt) 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-09 22:40:08 -07:00
Maximilian Roos 6df9623e23 docs(state): distinguish reading the cache from resolving it (#3028)
Documentation follow-up to #3024 (which made `wt config state get` read
the default-branch cache read-only).

After that change the dump shows `DEFAULT BRANCH (none)` right after a
`clear`, while `wt config state default-branch get` still returns the
resolved branch. That contrast can read as inconsistent. Two small
additions make the model explicit:

- The `default-branch` `--help` gains one line: `default-branch get`
resolves and caches; the aggregate `wt config state get` only reports
the cache, so it can show `(none)` until something populates it.
- The `state` module spec gains a "Reading vs resolving" section
recording the invariant for future developers: the aggregate `get`
inspects read-only (`cached_default_branch()`,
`CachedCiStatus::list_all`), while a per-key `get` for a derived value
resolves it (`default_branch()`, `PrStatus::detect`).

No behavior change. The `docs/` and `skills/` mirrors and the `--help`
snapshot are regenerated from the source.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:08:10 -07:00
Maximilian Roos 5da0d2c3e4 docs: alias template-expansion timing; fix narrow help-test env redaction (#3006)
## Docs: alias template-expansion timing

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

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

## Factual corrections

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

## Test fix: narrow help-test env redaction

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

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:00:52 -07:00
Florian Ilch 3a4ae84715 Expose commit bodies to squash templates (#2983) 2026-06-05 18:40:09 -07:00
Maximilian Roos 7d16e63fad refactor(commit-generation): drop the CLAUDECODE= nesting workaround (#2979)
worktrunk stripped `CLAUDECODE` before spawning the
`[commit.generation]` LLM command, and the recommended Claude Code
command carried a leading `CLAUDECODE=`, both to get past Claude Code's
nested-session check that rejected `claude -p` launched from inside
another Claude Code session.

That check is gone. It's absent from the `claude` binary across every
current build (2.1.157 through 2.1.162), and `CLAUDECODE=1 claude -p …`
runs cleanly (exit 0, empty stderr). So the workaround is no longer
needed.

This drops it: `src/llm.rs` no longer calls `.env_remove("CLAUDECODE")`,
and the recommended command loses the `CLAUDECODE=` prefix in the source
of truth (`dev/config.example.toml`), the `wt config --help` text, and
the Taskfile bench command. Docs, the skill reference, and the config
help snapshots are regenerated to match.

Caveat: there's no published guarantee the nested-session check stays
gone, and a Claude Code old enough to still have it (older than roughly
late May 2026) would again block nested commit generation. Since the
check is absent from the implementation across all current builds, that
risk is low.

Tests: 1236 lib + 693 integration pass; fmt and clippy clean.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:26:32 -07:00
Worktrunk Bot d65d5028fa docs(commit-generation): bump Codex model to gpt-5.4-mini (#2949) 2026-06-02 00:42:18 -07:00
Maximilian Roos 2617348b22 docs: writing-prose cleanup (faq, config, list, remove) (#2925)
Continues the writing-prose pass on the remaining doc-site pages — the
items deferred at the end of #2922.

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

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

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

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

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

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

---------

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

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

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

## Change

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

## Notes for review

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

## Tests

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

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

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

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

## Change

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

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

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

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

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

## Implementation notes for review

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

## Deferred follow-ups

Surfaced during review but out of scope here:

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

## Tests

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

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

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

---------

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

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

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

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

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

## Smaller bits

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

## Testing

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

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

## Follow-up

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

Re #2838.

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

## What changes

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

## Semantic flip

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

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

## Reviewing this diff

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

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

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

## Testing

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

Part of #2838.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-20 19:31:50 -07:00
Worktrunk Bot 7f36ece541 feat(config): show project identifier in wt config show (#2827)
## Summary

Closes #2826. Surfaces the project identifier (`<host>/<owner>/<repo>`
from the primary remote, or the canonical repo path as a fallback) in
the PROJECT CONFIG section of `wt config show`, so users can find the
right key for `[projects."..."]` in their user config without
hand-deriving it. Also adds an `identifier` field to `wt config show
--format=json`.

Docs expand the *User project-specific settings* section with the new
how-to-find-it note plus single/concurrent/pipeline hook examples — the
reporter's TOML showed both pieces were unclear, even though the
array-of-tables form they tried was correct.

## Drive-by

`test_config_show_github_remote` and `test_config_show_gitlab_remote`
used `git remote add origin …` against the standard fixture, which
already has an `origin`. `git remote add` failed silently (output isn't
checked) and the test snapshotted the fixture's original `../origin.git`
URL instead of the platform URL it was meant to exercise. The new
Identifier line in the regenerated snapshot made this visible; both
tests now use `set-url`, matching the existing
`test_config_show_full_gitea_remote`.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` (passes; nushell-only
`shell_wrapper::*::case_4` failures are CI-environment, not regressions)
- [x] `cargo test --test integration` — 1738/1738 passing
- [x] `cargo test --lib --bins` — passing
- [x] `cargo test --test integration test_docs_are_in_sync`
- [x] Verified manually: `wt config show` in a repo with a
`github.com:max-sixty/worktrunk.git` remote prints `Identifier:
github.com/max-sixty/worktrunk`

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 06:21:45 +00:00
Maximilian Roos f522357e11 feat(commit): experimental project-level commit-message guidance (#2774)
## Summary

- Adds `[commit.generation] template-append` to **both** user config and
project config (`.config/wt.toml`). It *adds to* the commit/squash
prompt rather than replacing it (`template` still replaces). One field
covers both commit and squash.
- Each fragment is rendered as its own minijinja template with the same
variable context as the main template, then emitted in a
provenance-labeled block: `<user-guidance>` (the developer's own config
— no approval) followed by `<project-guidance>` (repo-supplied — gated).
Default templates render each block conditionally; `{{ user_guidance }}`
/ `{{ project_guidance }}` are exposed for custom user templates.
- The project fragment keeps the first-time approval gate — same
one-shot gate as project hooks. `wt merge` bundles it into the existing
hook-approval batch so the user sees one combined prompt. Declining is
non-fatal; the LLM runs with just the user fragment (if any). Skipped
automatically when no LLM command is configured.
- Marked experimental in CLI help, docs, and the user/project config
docstrings.

Discussion: #2758 (comment
[4454619614](https://github.com/max-sixty/worktrunk/issues/2758#issuecomment-4454619614))

## Test plan

- [x] Unit tests for `commit_template_append()` and the user-side
accessor (trim / empty / unset)
- [x] `Merge` test for user `template-append`
- [x] Snapshot tests confirming the default commit and squash templates
render the `<project-guidance>` block
- [x] Unit tests for user-side append: renders into `<user-guidance>`,
ordered before `<project-guidance>`, blank-is-unset, minijinja variable
expansion
- [x] PTY integration tests for the approval flow (accept / decline /
merge bundling), asserting `<project-guidance>`
- [x] `cargo run -- hook pre-merge --yes` — 3708 tests passing, clippy +
pre-commit clean

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-17 17:19:19 -07:00
Maximilian Roos 00c4508671 refactor(ci): move CiPlatform to the lib crate, cache on RepoCache, drop the "override" framing (#2692)
Follows up on #2686 (the original "dedup the warning" fix), which parked
the configured-platform state in a process-wide `OnceLock` because
`CiPlatform` lived in the binary crate and `RepoCache` in the library
crate.

**Move `CiPlatform` to the lib crate.** It's now
`worktrunk::git::CiPlatform` (a new `src/git/ci_platform.rs` module),
with `Repository::ci_platform(remote_hint)` replacing the free
`platform_for_repo` and a private `Repository::configured_ci_platform()`
reading `forge.platform` / `ci.platform`. The configured value caches on
a `OnceCell` field of `RepoCache` instead of a process global — same
once-per-`wt list` warning dedup, but the repo-scoped data no longer
lives in a process static. `src/commands/list/ci_status/platform.rs` is
now pure forge dispatch (`gh` / `glab` / `az`) over a `CiPlatform`; the
inherent `detect_*` methods became free functions so the enum could move
without dragging the bin-crate backends along.

**Drop the "override" framing.** `forge.platform` / `ci.platform` is now
described everywhere as "the platform, falling back to URL detection
when unset" rather than "overriding detection" — config docs (`## Forge
platform`), the `ProjectForgeConfig` / `ProjectCiConfig` struct and
accessor docs, `wt config state ci-status` help, and a few test names
(`test_list_full_with_configured_platform_github`,
`test_list_full_with_invalid_configured_platform`,
`test_switch_pr_gitea_forge_platform`). Genuine "override" uses
elsewhere (config-path/env-var overrides, `wt config state
default-branch set`) are untouched.

**Fix a pre-existing bug.** `forge.platform = "gitea"` is a valid value
— the `wt switch pr:` shortcut uses it to pick `tea` — but worktrunk
fetches CI status only from GitHub/GitLab, so `wt list` used to print
`Invalid CI platform in config: 'gitea'. Expected 'github' or 'gitlab'.`
for it. `configured_ci_platform()` now recognizes `gitea` as a known
forge it just doesn't show CI for: no warning, blank CI column. Added
`test_list_full_with_gitea_forge_platform` (asserts blank CI and empty
stderr).

Branch is merged with `main`, so the dispatch and detection handle the
new `AzureDevOps` variant alongside GitHub and GitLab.

`cargo run -- hook pre-merge --yes` passes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:26:12 -07:00