Commit Graph

146 Commits

Author SHA1 Message Date
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
Worktrunk Bot eddd280ce1 docs(step): prefix the copy-ignored --require-include example with $ (#3706)
Nightly sweep finding: the `--require-include` example in `wt step
copy-ignored`'s long help was the only `console` code block in
`src/cli/step.rs` missing the `$ ` prompt prefix.

Per the doc-sync convention (`docs/CLAUDE.md`: "All shell commands use
`$ ` prefix in ` ```console ` blocks"), a bare `console` block converts
to a plain ` ```bash ` fence in the web docs, whereas a `$ `-prefixed
one becomes a `terminal` shortcode. So this single example rendered
inconsistently with every other command block on the [step
page](https://worktrunk.dev/step/) — as a plain code fence rather than a
styled terminal line.

The primary source is `after_long_help` in `src/cli/step.rs`; the three
generated mirrors (`docs/content/step.md`,
`skills/worktrunk/reference/step.md`,
`plugins/worktrunk/skills/worktrunk/reference/step.md`) were regenerated
by `cargo test --test integration test_docs_are_in_sync`. No `--help`
snapshot changed — the terminal help renderer already styles the line
identically with or without the prefix; the fix is web-docs-only.

No regression test: this is a pure documentation-string change, and
`test_docs_are_in_sync` already enforces the mirror consistency.

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-02 09:30:09 -07:00
Maximilian Roos 4a6fd1ec44 refactor(merge): leave target-worktree changes in place, drop the autostash (#3703)
`wt merge` / `wt step push` previously moved a dirty target worktree's
uncommitted changes aside with an autostash (`git stash push -u`) and
restored them after the push. That design entered `refs/stash` — a
repo-global namespace any process can mutate, the source of #3683's race
class — restored staged changes as unstaged, and existed only because
the fast-forward's `receive.denyCurrentBranch=updateInstead` refuses any
dirty worktree at all.

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

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

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

Behavior changes:

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

Review highlights (three adversarial passes over the diff):

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

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 23:10:55 -07:00
Maximilian Roos 03f49ce93a fix(merge): exit non-zero when the target autostash can't be restored (#3693)
Follows #3684. The Windows test un-gating that was stacked here is split
out into #3695, now merged; this PR is the exit-code change alone.

## Problem

`wt merge` reported success when the target worktree's autostash failed
to replay. The warning named the recovery command, but exit 0 said the
user's uncommitted changes were back in their worktree while they were
still in a stash — and that warning scrolls past under the
worktree-removal and post-merge-hook output that follows it.

## Solution

The restore outcome travels out through `PushResult`. The command
finishes everything it started — ref advanced, worktree removed, hooks
run, `--format=json` payload printed — and only then returns
`AlreadyDisplayed { exit_code: 1 }`.

Aborting at the restore instead would leave a landed merge with its
cleanup half-done, trading a recoverable stash for a worse mess. The
shell wrapper applies its `cd` directive whenever the directive file is
non-empty, independent of exit code, so a non-zero exit strands nobody
in a removed directory.

Both output channels name the failure: the `--format=json` payloads of
`wt merge` and `wt step push` carry `stash_restore_failed`, present on
every payload like the other outcome booleans. The exit code alone would
leave a consumer reading stdout with a success-shaped object and no
signal.

This also closes a gap the change surfaced: `handle_no_ff_merge`'s
already-up-to-date early return never called `restore_stash`, so a dirty
target worktree with nothing to merge restored through `Drop` and
reported nothing. It restores on that path too now, which is what makes
the guarantee hold — every remaining `Drop` of the guard happens on a
path already returning an error.

## On diverging from git

`git rebase --autostash` exits 0 in this situation: it prints "applying
them resulted in conflicts" and still reports "Successfully rebased".
The difference is what the user is left looking at. git's failure leaves
conflict markers in the working tree, met immediately; a failed `git
stash apply` here can leave the worktree untouched — an untracked path
re-created underneath it, for instance — so nothing but the exit code
outlives the warning. The reason is recorded on the field the exit code
hangs off, so it doesn't read later as an oversight.

## Testing

`test_merge_autostash_restore_failure_exits_non_zero_after_cleanup`
covers the guarantee end to end: exit 1, `stash_restore_failed` in the
JSON, ref advanced, source worktree removed, stash entry still present
for recovery. `test_push_autostash_restore_failure_warns` moves from
asserting `success()` to asserting exit 1 plus the push having landed.

Also verified against a real build outside the suite, on both commands:
the merge lands, the worktree is cleaned up, exit is 1, the JSON reports
`"stash_restore_failed": true`, and the warning names the exact `git
stash apply <sha>`.

`wt step push --help` gained the failure contract, since it previously
described only the success path; the generated mirrors are regenerated
with it.

Local (macOS): `cargo run -- hook pre-merge --yes` green — 4500 tests,
clippy, fmt, doc sync.

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

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:13:39 -07:00
Maximilian Roos b17a1364f1 docs(step): render wt step rebase & wt step push on the docs site (#3578)
Follow-up from #3568, which named these two as the one plain oversight
in its audit of `after_long_help` bodies that never reach a docs page.

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

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

## What reviewing the text against the code turned up

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

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

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

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

## The code changes

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

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

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

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

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

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

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

## Found and not fixed here

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

## Verification

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 19:16:20 -07:00
Maximilian Roos 85c9241cb1 Name the template owner in the -v variables block (#3495)
## The question

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

## What was there

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

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

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

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

## The change

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 13:44:44 -07:00
Chris Chen 0b42d42670 feat(step promote): add --format text|json flag (parity with rebase/push/etc.) (#3424) 2026-07-12 02:01:05 -07:00
Zexin Yuan 6fc6b37b6b feat(step): add require-include option + CLI flags to copy-ignored (#3196)
`wt step copy-ignored` copies all gitignored files by default. Add a
`require-include` setting that gates the copy on a `.worktreeinclude`
file existing in the source worktree — matching Claude Code desktop,
where the file is required. Without `.worktreeinclude`, the command is a
no-op that reports why (text hint + JSON `reason`); with it, only
matching files copy.

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

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

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

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

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:54:24 -07:00
Maximilian Roos 5ce076c22c fix(step): apply -C to the tethered command; tether the docs server (#3207)
Make `wt step tether` honor the global `-C` flag as the spawned command's working directory (consistent with `run_custom`), and use it to tether this repo's `zola serve` docs preview so it is reaped whenever the worktree is removed — replacing the fragile [post-remove] port-kill hook that leaked ~83 zola processes.
2026-06-24 19:45:05 -07:00
Maximilian Roos 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 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
Worktrunk Bot 8c80e5a29c fix(step relocate): allow relocating dirty linked worktrees (#3104) 2026-06-18 08:09:43 -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
Worktrunk Bot ea126f1466 docs(step): regen wt step eval --format mirror (#3109) 2026-06-17 11:53:33 -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
Maximilian Roos c9cfc1ef16 feat(eval): add --format=json for structured template output (#3106)
Adds `--format=json` to `wt step eval`, the structured counterpart to its `-v` view.

`wt step eval` printed only the bare rendered result to stdout; the template's name, source, and rendered result were reachable solely from the `-v` human lane on stderr, with no machine-readable form. `--format=json` emits `{name, template, result}` to stdout — the structured analog of the `-v` expansion view, and consistent with `wt hook show --format=json` and the other `wt step` JSON lanes.

Text mode is unchanged (bare result to stdout). The two lanes compose: `--format=json -v` keeps JSON on stdout and the human expansion view on stderr, so `wt step eval --format=json -v … 2>/dev/null` yields clean JSON.

Implementation mirrors the sibling `wt step` commands: a `SwitchFormat` flag under the `Automation` heading, an inline `serde_json::json!` payload, and snapshot tests covering the JSON lane and the JSON + `-v` composition.

> _This was written by Claude Code on behalf of max_
2026-06-17 10:42:24 -07:00
Maximilian Roos 01242177d3 refactor(expand): label -v template expansion as source/result blocks (#3099)
Reworks the `-v` template-expansion view. It used to stack the template,
a lone `→`, and the result in one shared gutter; the arrow on its own
line read awkwardly and the shared gutter blurred input vs output.

Now each side is its own labeled block — an info header above a bash
gutter:

```
○ eval source
  {{ branch | hash_port }}
○ eval result
  12107
```

The two headers carry the input/output distinction, so both blocks keep
the same gutter with content at column 2 (no marker glyph, no in-gutter
indent). Multi-line templates and results follow the same shape, one
gutter line per source line.

`expand_template` emits no trailing blank. The standalone `wt step eval`
adds one after the view; in a command pipeline the executor already
separates the block from the next output (an unconditional trailing
blank there would trip the no-double-blank guard).

This view also surfaces in `wt switch -v`, hooks, aliases, and `wt -v
list`'s deprecation preview — all snapshots regenerated. Known rough
edge: `wt -v list` previews a deprecated `worktree-path` once per
worktree, and those previews now sit back-to-back without a separating
blank.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:52:59 -07:00
Maximilian Roos a01a9020ca refactor(eval): inspect template variables via -v, not --dry-run (#3078)
`--dry-run` previews a mutation a command would perform. `wt step eval`
mutates nothing — it expands a template and prints the result — so its
`--dry-run` was a category error: it dumped the full variable context as
raw `key=value` text, and was the only `--dry-run` in the CLI not using
the gutter house style.

This moves variable discovery to the verbose lane. `wt step eval -v` now
lists the available template variables on stderr in the gutter style,
above the `{{ template }} → result` expansion view that `-v` already
rendered:

```console
$ wt step eval -v '{{ branch }}'
○ Available template variables
  branch        = feature/auth-oauth2
  worktree_path = /home/user/projects/myapp-feature-auth-oauth2
  …
○ Expanding eval
  {{ branch }}
  →
  feature/auth-oauth2
feature/auth-oauth2
```

The result still goes to stdout, so `$(wt step eval …)` is unchanged.
`eval` is experimental, so `--dry-run` is removed outright rather than
deprecated — it now errors loudly instead of breaking silently.

The convention is documented in `src/commands/CLAUDE.md`: `--dry-run`
previews a mutation, so a command that changes nothing carries none, and
inspection belongs to `-v`. The other nine `--dry-run` commands already
conform (`config alias dry-run` and `hook --dry-run` use `info_message`
+ the gutter), so eval was the only outlier.

> _This was written by Claude Code on behalf of max_

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

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

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

## Factual corrections

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

## Test fix: narrow help-test env redaction

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

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:00:52 -07:00
Maximilian Roos 77f2f5a405 fix(help): render inline code in --help section headings (#3003)
Terminal `--help` applied heading styling to the raw header text without
stripping inline-code markers, so a heading authored as `` ### `--stage`
`` rendered with literal backticks. Header rendering now reduces inline
`code` and links to plain text, letting the heading's single uniform
style (color/weight) cover the whole line. A dim ANSI reset inside a
colored heading would otherwise terminate the heading color partway
through — visible on the `wt config state logs` heading `Command log
(`commands.jsonl`)`, where the trailing `)` follows the code span. That
heading is now fixed too.

Separately, renamed the `--stage` / `--dry-run` subsection headings in
`wt step commit` and `wt step squash` to sentence case ("Staging", "Dry
run"). They were the only flag-literal, inline-code headings in the
entire help system; every other heading uses sentence-case topic
wording, which the `writing-user-outputs` skill also prescribes. The
flag name is still surfaced by the clap `Options:` block and each
section's usage example.

The markdown source feeds three render contexts (terminal `--help`, web
docs, skill mirrors); only the terminal path was wrong. Doc mirrors
regenerated via `test_docs_are_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-06 20:17:01 -07:00
Worktrunk Bot b41e210f82 feat(step): add --branch arg to wt step diff (#2995)
## Problem

`wt step diff` could only diff the current worktree. #2994 (part of the
effort to extend `--branch` to more commands) asks for a `--branch` flag
so the diff can target another worktree's branch without leaving the
current one.

## Solution

Add `-b/--branch` to `wt step diff`, mirroring the existing flag on `wt
step commit`. When provided, the repo is rooted at that branch's
worktree (via `worktree_for_branch` + `Repository::at`) so both the diff
and its target/merge-base resolution operate there. The branch must have
a checked-out worktree; otherwise it errors with `no worktree for branch
'<b>'`.

## Testing

Two integration tests in `tests/integration_tests/step_diff.rs`:

- `test_step_diff_branch_arg` — runs `wt step diff --branch feature`
from the main worktree and asserts the output matches
`test_step_diff_committed_changes` (the same diff run from inside the
feature worktree).
- `test_step_diff_branch_no_worktree` — asserts the error path for a
branch without a worktree.

Docs regenerated via `test_docs_are_in_sync`; `cargo clippy
--all-targets` and `cargo fmt --check` are clean. The only failing tests
locally are the `case_4` nushell shell-wrapper tests, which fail because
`nu` isn't installed in this sandbox — unrelated to this change.

---
Closes #2994 — automated triage

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-06 18:50:21 -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 a2e95dbf6b refactor(prune): stream removals, skip unapproved hooks instead of prompting (#2908)
## Summary

Three changes to \`wt step prune\`, all pointing the same way — let the
scan run end-to-end and react to each result as it arrives.

- **Removability uses the same gate as \`wt remove\`.** Dirty / locked /
primary worktrees flunk \`prepare_worktree_removal\` and drop out
silently, before the age check. A young + dirty + integrated worktree
(HEAD == default branch, working tree modified) no longer surfaces as
\`Skipped (younger than 1d)\` — the dirty tree would have blocked
removal anyway. The \`(younger than X)\` message now fires only when the
worktree would actually have been pruned.
- **Scanning streams instead of batching.** One parallel pass per check
item bundles integration + removability + age in a single rayon worker.
Results flow through a channel; the main thread calls \`try_remove\`
immediately for positives instead of waiting for the full scan to
finish. Removals overlap with continued scanning (brief check-lock pause
per removal for the Windows \`.git/config\` race).
- **Hook approval moves out of the way.** Prune never prompts inline —
that would deadlock against the streaming structure. With \`--yes\`,
every project command is auto-approved. Without \`--yes\`,
already-approved commands run; a candidate whose hooks include any
unapproved project command is SKIPPED with \`(approval required)\` plus
a hint pointing at \`wt config approvals add\` (or \`wt -C <wt>
remove\`). Unapproved hooks never run silently.

New \`HookPlan::unapproved_project_commands\` exposes the templates that
gate the approval decision.

## Test plan

- [x] \`cargo run -- hook pre-merge --yes\` (3867 tests, lints, all
green)
- [x] \`test_prune_pre_remove_needs_approval\` updated to assert
skip-with-hint (not abort)
- [x] \`test_prune_unmerged_pre_remove_is_not_approved\` still passes —
unmerged worktrees are silently filtered before the approval check, no
spurious prompts
- [x] \`test_prune_non_current_removal_does_not_approve_post_switch\`
still passes — only Current candidates check post-switch
- [x] \`test_prune_runs_pre_remove_hook\` (\`--yes\`) still passes —
auto-approval path runs hooks in full
- [x] Removed \`test_approval_prompt_prune_decline\` (PTY-based,
asserted the old prompt-and-decline path that no longer exists)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:09:07 -07:00
Maximilian Roos 818aef5c5c feat(prune): bump default --min-age from 1h to 1d (#2886)
## Summary

- Raise the default `--min-age` guard in `wt step prune` from `1h` to
`1d`.
- A worktree just created from the default branch looks "merged" because
its branch still points at the same commit; a one-day floor keeps an
unattended prune from sweeping it up before its owner has a chance to
start work.
- Explicit `--min-age=0s` or any other value is unchanged.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` (3821 tests + lints green)
- [x] `cargo run -- step prune --help` shows `[default: 1d]`
- [x] Docs synced: `docs/content/step.md`,
`skills/worktrunk/reference/step.md`
2026-05-23 12:45:10 -07:00
Maximilian Roos 9e8c028476 fix(relocate): atomic no-overwrite rename for --clobber backup (#2865)
`wt switch --clobber` and `wt step relocate --clobber` each had their
own logic for backing up a path blocking the target, and the two had
diverged. relocate used an `exists()` check followed by
`std::fs::rename` — a time-of-check/time-of-use race, since
`std::fs::rename` silently replaces an existing file or empty directory
on Unix and could destroy a just-created backup. switch (#2849) instead
used `renamore::rename_exclusive`, an atomic no-overwrite rename, and
counted up through suffixes (`…-2`, `…-3`, …) on a name collision rather
than failing.

This PR replaces both with one shared helper in `commands::backup`.
`back_up_clobbered_path_now` computes the timestamped name and delegates
to the suffix-iterating `back_up_clobbered_path`, which moves the
blocker with `renamore::rename_exclusive` — atomic and no-overwrite, so
an existing backup is never clobbered; a name collision just lands on
the next free `-N` name. relocate gains the suffix fallback (previously
it failed closed on `AlreadyExists`), the timestamp computation is no
longer duplicated across the two call sites, and
`renamore::rename_exclusive` now has a single caller.

`renamore` is already a direct dependency and keeps the platform FFI and
its `unsafe` inside that crate, so worktrunk stays `unsafe_code =
"forbid"`.

relocate's backup name changes from `.bak-<timestamp>` to the
extension-aware `.bak.<timestamp>` form used by switch (e.g.
`repo.feature` → `repo.feature.bak.<timestamp>`). The
`test_relocate_clobber_error_backup_exists` regression test asserted the
old fail-closed behavior and is rewritten as
`test_relocate_clobber_falls_back_when_backup_taken`, mirroring the
switch test. CLI help and the generated doc mirrors are updated for the
new naming and fallback.

The shared helper carries the suffix-iteration, collision, and
missing-source unit tests (moved from `resolve.rs`); the relocate
`--clobber` path keeps its integration coverage.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:43:12 -07:00
Maximilian Roos 61f4efc1e4 refactor(cli): consolidate --no-verify deprecation into one path (#2868)
The deprecated `--no-verify` flag was declared as a clap arg five times
— across `SwitchArgs`, `RemoveArgs`, `CommitArgs`, `SquashArgs`, and
`MergeArgs` — under two field names, resolved through two helpers, with
the deprecation-warning string hand-duplicated in two places.

This collapses the four bool-resolving commands (`switch`, `remove`,
`step commit`, `step squash`) onto a single flattened `HookFlags` args
struct with one `resolve()` method, and routes every `--no-verify`
deprecation warning — merge's included — through one emitter, so the
warning text exists in exactly one place.

`wt merge` keeps its own flags: its hooks flag is genuinely tri-state
(`Option<bool>`, so config `[merge] verify` still applies) with a
positive `--verify` override, which `HookFlags`'s
`SetFalse`/default-true shape cannot express. It shares only the warning
emitter — forcing it into the struct would need a special case.

Behavior is unchanged: `--no-verify` still works as a deprecated alias
and emits the same warning under the same conditions. One visible
`--help` change, a direct consequence of the shared struct: `--no-hooks`
for `step commit`/`step squash` moves under the `Automation` heading,
matching where `switch`/`remove`/`merge` already place it. Doc mirrors
regenerated.

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

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

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

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

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

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

## Smaller bits

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

## Testing

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

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

## Follow-up

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

Re #2838.

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

## What changes

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

## Semantic flip

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

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

## Reviewing this diff

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

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

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

## Testing

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

Part of #2838.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-20 19:31:50 -07:00
Maximilian Roos 408f4f5bee Add wt step tether: kill a command's process group when its worktree is removed
`wt step tether -- CMD…` runs CMD in its own process group and tears the whole
group down when CMD exits or its worktree is removed (a 250ms portable poll;
killpg on Unix, taskkill /T /F on Windows). Replaces the leaked-dev-server /
fseventsd-saturation failure mode with a fire-and-forget supervisor needing
only a single post-start hook. No unsafe, no new deps. Shell handling matches
`wt step for-each`. Windows taskkill has a documented self-exit-detach edge.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 11:57:16 -07:00
Worktrunk Bot dc3142714c docs(step): match the eval --dry-run separator to actual output (#2666) 2026-05-10 08:18:15 +00:00
Worktrunk Bot a77c92e25b docs(help): point banner at the actual cli source path (#2665) 2026-05-10 08:14:37 +00:00
Maximilian Roos 275eaead91 feat: add --format=json to seven step + hook commands (#2560)
Extends structured-output coverage beyond
`list`/`switch`/`remove`/`merge` to the remaining commands where JSON is
useful for scripting and Claude Code integration: `step rebase`, `step
push`, `step commit`, `step squash`, `step relocate`, `step
copy-ignored`, and `hook show`.

JSON shapes follow the existing pattern (additive on stdout; human prose
stays on stderr) and use stable snake_case `outcome` discriminators
where the result is one of several variants.

## JSON shapes

| Command | Payload |
|---|---|
| `step rebase` | `{target, outcome:
"rebased"\|"fast_forwarded"\|"up_to_date"}` |
| `step push` | `{target, outcome:
"fast_forwarded"\|"up_to_date"\|"merge_commit", commits, merge_sha?}` |
| `step commit` | `{commit, message, stage_mode}` (resolved mode, not
raw flag) |
| `step squash` | `{outcome:
"squashed"\|"no_commits_ahead"\|"already_single_commit"\|"no_net_changes",
commit?, message?, stage_mode?, target?}` |
| `step relocate` | `{dry_run, entries: [{branch, from, to}], skipped:
[{branch, reason}]}` |
| `step copy-ignored` | `{outcome, dry_run, from, to, entries: [{path,
kind}], files, bytes}` |
| `hook show` | `[{type, source, name, template, needs_approval,
expanded?}]` |

## Notable refactors

- `RebaseResult::Rebased` now carries `{target, fast_forward}`.
- `SquashResult::Squashed` now carries `{sha, message, stage_mode}`.
- `CommitOutcome` returned from `commit_staged_changes` and
`CommitOptions::commit` carries `{sha, message, stage_mode}` — the
*resolved* mode that was actually applied (CLI flag merged with config
defaults), not the raw `Option<StageMode>` from clap. Without this,
`--format=json` would emit `null` whenever the user didn't pass
`--stage`.
- `handle_push` / `handle_no_ff_merge` return `PushResult { target,
commit_count, outcome }` with outcome variants `FastForwarded` /
`UpToDate` / `MergeCommit { merge_sha }`. The merge-commit SHA was
previously discarded.
- `relocate::GatherResult` carries `template_error_branches` (was:
opaque count). Text mode already showed these in stderr; JSON now
surfaces them as `skipped` entries with `reason: "template_error"` so
automation can detect a broken `worktree-path` config rather than
reading `entries: []` as success.

## Behavioral guards

`--show-prompt` is rejected when combined with `--format=json` for `step
commit` / `step squash` — show-prompt emits raw LLM-prompt text that
would corrupt a JSON consumer's stdout.

## Reviewed by Codex

Two rounds. Round 1 caught the relocate template-error case (fixed in
this PR). Round 2 reported no remaining correctness issues.

## Tests

13 new integration tests covering the JSON shapes (parsed into
`serde_json::Value` and asserted against concrete keys). Full suite:
1604 / 1604 passing locally.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 17:24:10 -07:00
Maximilian Roos e5d0c138ea Add wt step {commit,squash} --dry-run, hide --show-prompt (#2557)
Adds `--dry-run` to `wt step commit` and `wt step squash`. It renders
the prompt, prints the shell invocation that would call the LLM, calls
the LLM and prints the generated message in three labeled sections
(PROMPT, COMMAND, MESSAGE), then exits without staging, running hooks,
or committing. For commit, `--stage` is honored against a temp index —
the previewed prompt matches what a real run would send the LLM, but the
user's real index is never touched. Output is routed through
`show_help_in_pager` so the prompt section (50+ lines) doesn't scroll
off; piping (`| grep`, `| jq`) still works because the helper
TTY-detects.

`--show-prompt` is hidden via clap (`hide = true`) but kept working —
it's still the right shape for piping the cheap rendered prompt to
another LLM (`wt step commit --show-prompt | llm -m gpt-5-nano`), and
`GitError::LlmCommandFailed` still suggests it as a reproduction
command.

A new "When to page output" section in the `writing-user-outputs` skill
documents the policy: page long human-oriented stdout (`--help`, `wt
config show`, `wt hook show`, `--dry-run`); don't page pipe-first data
or output already paged by a delegated tool (`git diff`).

Follow-ups during review: `render_llm_invocation` now uses the shell's
basename (no install-path leakage), with an insta filter normalizing
`bash.exe` → `sh` for cross-platform snapshot stability. Added a
`run_git_capture` helper around the new direct `Cmd::new("git")` calls
that bails on non-zero exit (was a non-blocking reviewer observation —
without it, a failing `git diff --staged` would silently feed an empty
diff to the LLM). `stage_to_temp_index` was refactored to take argv
directly, eliminating a dead `StageMode::None` arm. Added unit tests for
`render_llm_invocation` and integration tests for `--dry-run
--stage=tracked` and `--dry-run` without LLM configured.

`codecov/patch` reports 95.29% with 9 misses. The remaining gap is split
between error-path fault-injection (`stage_to_temp_index` git-add
failure, `show_help_in_pager` spawn failure) and a codecov attribution
mismatch — local `cargo llvm-cov` shows `render_llm_invocation` as
covered by the new unit tests, but codecov doesn't credit them. Merged
with explicit override.

Test plan:
- [x] `cargo test --test integration -- merge::test_step_commit_dry_run
merge::test_step_squash_dry_run merge::test_step_commit_show_prompt
merge::test_step_squash_show_prompt`
- [x] `cargo run -- hook pre-merge --yes` (3438 tests pass, clippy
clean)
- [x] End-to-end smoke test in `/tmp/wt-dry-test`: untracked file
appears in dry-run prompt, real index stays untouched
- [x] `--dry-run` and `--show-prompt` are mutually exclusive (clap
`conflicts_with`)
- [x] CI green on Linux/macOS/Windows after the cross-platform shell
filter fix

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 13:51:19 -07:00
Worktrunk Bot f418729e21 fix(step/copy-ignored): drop .pi/ from built-in excludes (#2527) 2026-05-02 06:13:49 -07:00
Worktrunk Bot 6eaf349711 refactor(for-each): exec argv directly, drop implicit shell (#2465)
## Problem

`wt step for-each` rebuilt the post-`--` command with `args.join(" ")`
before passing it to `sh -c`. That dropped the user's quoting and argv
boundaries, so anything with spaces, `;`, or shell metacharacters inside
an argv element broke. The reproduction from the issue:

```console
$ wt step for-each -- python3 -c 'import sys; print(sys.argv[1:])' 'a b'
sh: 1: Syntax error: word unexpected (expecting ")")
```

## Solution

Drop the implicit shell. The post-`--` argv is the program and its
arguments, exec'd directly via `Cmd::new(program).args(rest)`. Templates
expand into argv elements with `shell_escape=false`. Users wanting shell
features (pipes, redirects, `$VAR`, globs) write `sh -c '<snippet>'`
explicitly — the same pattern as `xargs`, `find -exec`, `kubectl exec
--`, and `docker run`.

This started as the smaller fix that landed on the previous commit
(shell-escape each argv element before joining, with an arity branch —
one arg meant "shell snippet," many args meant "argv to escape and
re-join"). On review the arity asymmetry felt wrong; the design
discussion concluded that direct exec is one rule with no quoting
recovery pipeline, and the #2461 bug becomes structurally impossible.
`for-each` is still tagged `[experimental]`, so the small breakage of
the documented snippet form is acceptable.

After the fix:

```console
$ wt step for-each -- python3 -c 'import sys; print(sys.argv[1:])' 'a b'
['a b']

$ wt step for-each -- sh -c 'git status | wc -l'
[per-worktree counts...]
```

### What changes for users

| Before | After |
|---|---|
| `wt step for-each -- 'git status \| wc -l'` | `wt step for-each -- sh
-c 'git status \| wc -l'` |
| `wt step for-each -- 'echo Branch: {{ branch }}'` | `wt step for-each
-- echo 'Branch: {{ branch }}'` (or wrap in `sh -c`) |
| `wt step for-each -- python3 -c '...' 'a b'` ✗ broken | `wt step
for-each -- python3 -c '...' 'a b'` ✓ works |

The `for-each` help/docs are rewritten around the simpler model (one
rule, two example blocks).

## Testing

- `test_for_each_preserves_argv_quoting` covers the exact reproduction
from the issue and is now exercising a load-bearing property of the
design rather than a join bug.
- `test_for_each_aborts_on_signal_exit` rewritten to use `sh -c
'<snippet>'` for the shell features it needs.
- `test_for_each_json_spawn_failure` simplified — direct exec means a
missing program is enough; the PATH/symlink dance for forcing sh-spawn
failure is no longer needed.
- `cargo test --test integration for_each` — 16 passed.
- `cargo run -- hook pre-merge --yes` — 3403 passed, 0 skipped, all
lints green.

---
Closes #2461 — automated triage

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-04-30 12:37:34 -07:00
Maximilian Roos 4cbc8ca5fd refactor(docs): drop mirrored help-page close; align convert_console_blocks_in_docs error channel (#2422)
## Summary

Three follow-ups from #2419 review.

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

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

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

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

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

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

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

## Test plan

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

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

---------

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

Two follow-ups from #2418 review.

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

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

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

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

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

Net diff: −54 lines.

## Test plan

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 19:20:44 -07:00
Maximilian Roos 1e053d2d88 refactor(docs): fix blank-line cmd-stream corruption; share AUTO-GENERATED marker constants (#2417)
## Summary

Three small follow-ups + a placeholder rename, all from #2405 review
feedback.

- **Root-cause fix for `|||` corruption.**
`convert_dollar_console_to_terminal` was promoting blank lines inside
mixed `\$ cmd + output` blocks to extra `cmd=` entries, dropping the
blank from the rendered body and emitting a stray `\$` prompt (e.g.
`cmd=\"wt list|||\"`). Now distinguishes command-only blocks (blanks are
visual spacing in `cmd=`) from mixed blocks (blanks belong to body).
Removes the matching `trim_end_matches('|')` bandage in
`expand_command_placeholders`. New unit test in `src/docs.rs` covers the
mixed-block case.
- **Single-pass classification.** Folded the two filter chains over
`block_lines` into one loop with one match — fewer branches, no
duplicated predicates.
- **AUTO-GENERATED marker consolidation.** Within
`tests/integration_tests/readme_sync.rs`, the `<!-- ⚠️ AUTO-GENERATED
... -->` literal appeared in four producer / regex sites with subtly
different shapes (HTML vs plain, ID format). Factored into
`MARKER_OPEN_PREFIX` / `MARKER_OPEN_HTML_PREFIX` / `MARKER_CLOSE`
constants plus a `wrap_in_marker()` helper, and threaded those constants
into the regexes via `regex::escape`. Pure refactor — no docs/skill
files regenerated.
- **`__WT_OPEN2__` / `__WT_CLOSE2__` → `__WT_OPEN__` / `__WT_CLOSE__`.**
The `2` was meant to distinguish doubled-brace placeholders from
hypothetical single-brace ones, but Tera only treats `{{`/`}}` (not
single braces) as template delimiters — there's no second variant to
disambiguate from. Updated the producer (`src/docs.rs`), the test-side
decoder (`tests/integration_tests/readme_sync.rs`), the Zola template
(`docs/templates/shortcodes/terminal.html`), the docs-site `CLAUDE.md`,
and the regenerated `docs/content/{step,hook}.md`.

## Test plan

- [x] `cargo test --lib
docs::tests::test_convert_dollar_console_to_terminal` — exercises new
mixed-block case + existing command-only / multi-cmd / comment cases
- [x] `cargo test --test integration readme_sync` — 13 sync tests still
pass after the refactor and bandage removal
- [x] `cargo test --test integration` — full integration suite (1558
tests) pass
- [x] Visual check via local Zola dev server: `/merge/`, `/step/`,
`/remove/`, `/hook/`, `/llm-commits/`, and `/list/` all render terminal
blocks cleanly with no `|||` artifacts and no leaked placeholder strings
(`__WT_OPEN__` etc. don't appear in served HTML). The multi-command jq
recipes block on `/list/` continues to render comments as bash-styled
section headers.
- [x] `cargo clippy --all-targets --all-features` — clean
- [x] `cargo fmt --check` — clean

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

---------

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

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

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

## What changed

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

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

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

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

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

## Test plan

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

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

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-04-24 16:40:21 -07:00
Worktrunk Bot ea505bebf9 fix(step): gate copy-ignored self-lower on WORKTRUNK_FOREGROUND=-1 (#2358)
## Summary

Addresses #2342 — `wt step copy-ignored` has been much slower on macOS
since v0.37.0 because `taskpolicy -b` throttles disk I/O, and that hits
foreground callers (interactive use and `pre-*` hooks) as well as
background ones.

Per the [design proposed in the
issue](https://github.com/max-sixty/worktrunk/issues/2342#issuecomment-4290580468):

- Export `WORKTRUNK_FOREGROUND=-1` when spawning a background hook
pipeline (detached `wt hook run-pipeline`). The env var is inherited by
every descendant — shell, user command, nested `wt` invocations.
- `wt step copy-ignored` self-lowers only when it sees that sentinel;
interactive runs and synchronous `pre-*` hooks run at normal priority.
- Documented in `wt step copy-ignored --help` under a "Background-hook
priority (experimental)" section. Variable name and value flagged as
not-yet-stable.

Surface area:

- `src/priority.rs` — new `FOREGROUND_ENV_VAR` / `BACKGROUND_HOOK_VALUE`
consts and `in_background_hook()` helper, with a testable inner fn
(`is_background_hook_value`) so we can unit-test the match without
mutating process-global env.
- `src/commands/process.rs` — `spawn_detached_exec` now sets the env var
on the detached runner when the log variant is `HookLog::Hook`. Internal
ops (removal, trash-sweep) don't set it.
- `src/commands/step_commands.rs` — `copy-ignored` gates the existing
`lower_current_process()` call on `in_background_hook()`.
- `src/cli/step.rs` + auto-synced docs — experimental note, and removed
a now-stale paragraph that claimed unconditional `nice 19`.
- Integration test verifies `pre-start` sees the var unset and
`post-start` sees `-1`.

## Test plan

- [x] `cargo test --lib --bins` — 601 passed
- [x] `cargo test --test integration
test_background_hook_sees_worktrunk_foreground_env_var` — passes
- [x] `cargo test --test integration step_copy_ignored` — 44 passed
- [x] `cargo test --test integration user_hooks` — 106 passed
- [x] `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync` — passes (docs
auto-synced)
- [x] `cargo test --test integration test_help` — 40 help snapshots pass
- [x] `cargo clippy --all-targets` — clean

---------

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

## What's in here

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

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

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

## Example

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

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

---------

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

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

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

## Key files

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

## Testing

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

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

---------

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

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

## Why

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

## Call-site survey

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

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

## Alias compat

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

## Navigating the diff

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

## Notes

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

## Test plan

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

> _This was written by Claude Code on behalf of Maximilian_

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

---------

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

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

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

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

Follows #2271 and #2272.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:58:08 -07:00
Maximilian Roos a1be5645c4 feat(alias): dispatch aliases from top-level wt <name> (#2266)
`wt deploy` now resolves `deploy` against configured aliases before
falling through to a `wt-deploy` PATH binary. Built-ins still win (clap
matches before alias dispatch ever runs), and `wt step <name>` keeps
working at runtime — only the docs cut over to the new form.

## Why

`wt deploy` reads better than `wt step deploy`, and aliases as
first-class commands lower friction for using them as everyday
shortcuts.

## Precedence

built-in (clap) → alias (user/project config, merged) → `wt-<name>` PATH
binary → "unrecognized subcommand" error.

User config wins over PATH binaries because aliases are how users
customize wt — same model as git, where `[alias]` entries shadow
`git-foo` externals.

## Navigating the diff

- `src/commands/alias.rs` — refactored `step_alias` to share `run_alias`
with the new `try_alias(name, rest) -> Result<Option<()>>`. Returns
`Ok(None)` when the name isn't a configured alias or when not in a git
repo; propagates config-load errors so a broken `wt.toml` fails loudly
instead of silently turning into "unrecognized subcommand". Argument
parsing is gated on alias-membership, so unrelated args meant for an
external binary don't surface as alias parse errors. New
`alias_names_for_suggestions()` mixes alias names into "did you mean"
hints. `HelpContext` enum lets the help splice annotate "(shadowed by
built-in)" against the right level (top-level builtins for `wt --help`,
step builtins for `wt step --help`). The user-facing "shadow warning"
was removed entirely — under the new model an alias named `commit` runs
fine via `wt commit`, only `wt step commit` is shadowed.
- `src/commands/external.rs` — `handle_external_command` calls
`try_alias` first, then PATH lookup, then unrecognized-subcommand error.
Suggestions include alias names. Non-UTF-8 args bypass alias dispatch
(alias parser requires UTF-8; binary subcommands get raw `OsStr`).
- `src/help.rs` + `src/main.rs` — early-parse pass returns
`Option<HelpContext>`; help splice fires for both `wt --help` and `wt
step --help`.
- `src/completion.rs` — aliases injected at the top level in addition to
`step`.
- `src/cli/mod.rs` — long Aliases section moved out of
`Step::after_long_help` into hand-authored `docs/content/extending.md`.
New sync test `test_top_level_builtins_match_clap` keeps the
`TOP_LEVEL_BUILTINS` constant aligned with the `Cli` enum.

## Tests

3221 tests pass, lints clean. New integration tests:
`test_top_level_alias_dispatch`,
`test_top_level_alias_with_step_builtin_name`,
`test_top_level_alias_did_you_mean`. Removed
`test_step_alias_shadows_builtin_plural` (warning gone). Reframed
`test_step_alias_shadows_builtin` to verify shadow filtering of typo
suggestions instead. Completion tests now isolate user config via
`WORKTRUNK_CONFIG_PATH=/dev/null` — project config isolation is a noted
gap (commented inline).

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

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

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

## Change

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

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

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

## Fallback path

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

## Key files

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

## Testing

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

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

---------

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

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

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

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

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

## Reviewer navigation

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

## Testing

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 18:17:33 -07:00
Maximilian Roos faab53f1f8 feat(step): show aliases in wt step --help; list user + project separately (#2141)
Follow-ups from #2131, addressing two gaps raised in that review.

## 1. `wt step --help` / `-h` now shows configured aliases

In the original PR, the Aliases section appeared only when running bare
`wt step`, because clap's `DisplayHelp` error (triggered by `--help` /
`-h`) was intercepted in `help.rs` before `step_list()` ran.

Made `StepCommand` required (`arg_required_else_help = true`) so bare
`wt step` triggers the same `DisplayHelp` path. The splice now lives in
`help.rs::maybe_handle_help_with_pager`, scoped to the step subcommand
via a small `is_help_for_step` positional-args scan. `step_list()` and
its help-rendering helper are deleted — one entry point, one splice.

## 2. User + project aliases show as two rows, not a merged summary

Previously, when user and project both defined the same alias name,
`load_aliases_for_listing` called `append_aliases` which merged them via
`merge_append`. The summary for that merged config lost the original
per-source command text (often reduced to `<2 steps>` for unnamed
singles).

Now each source contributes its own row, user first (matching runtime
order — both still run). Rows get `(user)` / `(project)` markers only
when the name appears in both; unique names stay unannotated.

## Testing

Unit test covers the new render behavior (unique names, collision with
two rows). Integration test covers `wt step -h` showing aliases.
Existing `test_step_list_with_aliases` / `test_step_list_no_aliases`
snapshots picked up the new rendering path (clap help →
`md_help::render_markdown_in_help_with_width` instead of direct
`eprint!`); `help_step_long` / `help_step_short` and the generated docs
picked up `[COMMAND]` → `<COMMAND>` from the required-subcommand change.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:49:55 -07:00
Maximilian Roos 3f44cb568a docs(aliases): teach [[aliases.NAME]] pipeline blocks (#2144)
Aliases share the `CommandConfig` deserializer with hooks, so
`[[aliases.NAME]]` blocks already deserialize into pipeline steps with
zero code changes — analogous to the recent `[[hook]]` cutover in
32bc61787. Teach the block form as the canonical shape for multi-step
aliases.

Changes:
- `src/cli/mod.rs` — new paragraph + `[[aliases.release]]` example in
the `wt step` Aliases section, explaining sequential blocks with
concurrent keys and fail-fast on step failure
- `src/config/project.rs` — rewrite the `aliases` field rustdoc to treat
string / named-table / `[[aliases.NAME]]` as three first-class forms
(drops the "single-string is the expected usage" framing)
- `docs/content/step.md` + `skills/worktrunk/reference/step.md` —
auto-synced
- help snapshot re-accepted

No deserializer work — covered by existing
`test_deserialize_pipeline_named_single` in `src/config/commands.rs`.

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 17:41:10 -07:00