Repository navigation
fix(output): don't panic when a --format=json consumer closes the pipe - #3746
Conversation
The `--format=json` printer is shared by `wt list`, `wt list statusline`, and `wt config approvals list`; it lived under `commands::list` because that command needed it first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirty call sites repeated `println!("{}", serde_json::to_string_pretty(&x)?)`,
four of them through anstream's `println!` and the rest through std's. That
split was load-bearing: anstream drops a `BrokenPipe` write error, std panics,
so `wt config state get --format=json | head` died with "failed printing to
stdout" while `wt config show --format=json | head` exited cleanly.
`print_json` now prints through anstream, and every JSON surface with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Two findings, then why this is a comment rather than an approval.
STDOUT_ALLOWED_PATHS has seven entries that just went dead. Routing these files through print_json removed their last direct println!, so the matching exemptions in tests/integration_tests/output_system_guard.rs (the STDOUT_ALLOWED_PATHS const — the entries commented "JSON output for wt list --format=json", "…wt merge…", "…wt remove…", "…wt step for-each…", "…wt config show…", "…wt step relocate…", and "Dry-run cache inventory dump") now cover no code at all: list/mod.rs, merge.rs, remove.rs, for_each.rs, config/show.rs, step/relocate.rs, and picker/mod.rs each contain zero println!/print! after this change. The guard quietly stops protecting exactly the seven files whose stdout writes this PR centralized, so a future accidental println! in merge.rs or remove.rs passes it silently. (alias.rs's entry is dead too, but that predates this PR.)
Worth pairing the prune with the positive rule while the invariant is fresh: the thing this PR wants to stay fixed is serde_json::to_string_pretty + println! reappearing under src/commands/, and src/output/json.rs now sits outside the tree that guard scans, which makes it the one sanctioned writer — expressible as a second check in the same test. That also seems like the cheaper protection than a real broken-pipe integration test, which is hard to make deterministic: with a sub-buffer payload the child's write usually lands before the reader goes away. (If you do try one, it has to spawn the built binary — anstream::println! delegates to ::std::println! under cfg!(test), so an in-process test can't reproduce the fix either way.) Happy to push a commit with the prune plus the guard rule if you want it.
wt switch --format=json is the one surface that didn't get converted. emit_switch_json in src/commands/worktree/switch.rs still open-codes serde_json::to_string(&json) followed by println!("{json}"), and switch.rs's worktrunk::styling import brings in eprintln but not println — so that's std's macro, the panicking one. It's compact rather than pretty, so it isn't a print_json drop-in, but it means "every --format=json surface tolerates a consumer that stops reading" isn't quite true yet, and the CLAUDE.md line this PR adds doesn't describe it. Not a live panic — a single switch result never fills the pipe buffer, same as the schema-1 empty-path println!("[]") in statusline.rs — so this is about the map matching the territory. Suggestion inline on the doc line; routing emit_switch_json through print_json instead (and dropping the caveat) is the other way to close it, at the cost of switching that output from one line to pretty.
Not approving: the diff edits src/commands/remove.rs, src/commands/step/prune.rs, and src/commands/picker/mod.rs, all on the removal path, so per this repo's review policy the merge call is a human's — even though the edits there are confined to the JSON print and can't reach deletion behavior.
…d guard entries Routing the other JSON surfaces through `print_json` left `src/commands/` with no direct `println!` in eight allowlisted files, so their `STDOUT_ALLOWED_PATHS` exemptions covered no code — the guard silently stopped protecting exactly the files this branch centralized. Pruned; a stray `println!` in `merge.rs` or `remove.rs` now fails the test again. (`alias.rs`'s entry was already dead.) `emit_switch_json` keeps its compact single line — a shell loop reads a line — but prints it through anstream too, so no `--format=json` surface panics on a closed pipe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
One surface left, and a correction to what I said last time.
wt list statusline --format=json still writes through std's println!. run_json's print_empty closure emits println!("[]") on the schema-1 branch, and statusline.rs's worktrunk::styling import brings in fix_dim_after_color_reset, terminal_width_for_statusline, and truncate_visible — no println — so that's std's macro. It's the last --format=json write left on the panicking path, on the one surface that runs on every prompt redraw.
Correcting myself, because it's what makes this a bug rather than a doc nit: I wrote last time that the switch and statusline cases were "not a live panic — a single result never fills the pipe buffer." That's wrong. EPIPE turns on whether a reader is still attached, not on how much you write — a three-byte println!("[]") panics with failed printing to stdout: Broken pipe (os error 32) the moment the consumer has closed, which I verified with a standalone binary rather than reasoning about it a second time.
The fix wants to be one qualified call, not a module-level import: importing println into statusline.rs would also route the text statusline's println!("{}", output) through anstream, and that one deliberately bypasses it — "Output uses println! for raw stdout (bypasses anstream color detection)" — so its pre-rendered ANSI survives a non-tty stdout.
worktrunk::styling::println!("[]");Same qualified form config/show.rs carried before this PR. It also makes the new src/commands/CLAUDE.md line true as written — as it stands, wt switch --format=json isn't the only exception the line claims it is. Happy to push it.
Not approving, unchanged from last time: the diff edits src/commands/remove.rs, src/commands/step/prune.rs, and src/commands/picker/mod.rs, all on the removal path, so per this repo's review policy the merge call is a human's.
|
Both findings verified against head and fixed in 270d063. Dead guard entries. Confirmed by replicating the guard's own line logic over each allowlisted path: eight entries matched zero lines — the seven you named plus
So switch.rs now imports anstream's The doc line reads as the rule with switch falling out of it, rather than as an exception to it:
Agreed on the broken-pipe integration test, and thanks for the On the removal-path files:
|
|
Both first-round findings check out at 270d063 — eight allowlist entries gone, and Two things your replies don't cover, both from the second review (posted 00:31, ~30 min before them) rather than the first.
The fix wants to be one qualified call rather than a module-level import, since line 848's worktrunk::styling::println!("[]");That also makes the new CLAUDE.md line true as written — as it stands, the printer rule isn't absolute and On the gap you asked about (
Verified against the Codecov compare API
Summing
Base coverage from The remaining sixteen files with patch lines are all 100%. Unchanged on approval: the diff touches |
`run_json`'s schema-1 empty path wrote `println!("[]")` with std's macro — the
last `--format=json` write left on the panicking path, on the surface that runs
on every prompt redraw. It goes through `print_json` instead, which serializes
an empty array to the same two bytes (`test_statusline_json_outside_worktree`
asserts them).
A module-level `println` import would have been the wrong fix here: the text
statusline's `println!` deliberately bypasses anstream so its pre-rendered ANSI
survives a non-tty stdout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Verified and fixed in bb351ba — and thank you for the correction, which was the load-bearing part. I had the same wrong reasoning in the PR description ("payloads under the 64KB pipe buffer never reach the failing write"); it's now corrected there too. The size effect is real but it only shifts the odds of the race — it decides whether the write lands before the consumer goes away, not whether I took a different fix from the one you suggested. Rather than The reason to prefer it: your version leaves Your caveat about the module-level import was exactly right and is why this is a call-site change either way:
Understood on the removal-path policy — I'm taking the merge call to the repo owner along with the
|
worktrunk-bot
left a comment
There was a problem hiding this comment.
Last finding is addressed. Routing the schema-1 empty branch through print_json is the better of the two fixes — it leaves the text statusline's deliberate std println! alone, and serde_json::to_string_pretty renders an empty array as the same bare [] that test_statusline_json_outside_worktree asserts.
I also checked the STDOUT_ALLOWED_PATHS entries that survive and still carry a --format=json path — step/prune.rs, step/copy_ignored.rs, config/state.rs, hook_commands.rs, eval.rs. Each imports worktrunk::styling::println at module level, so their bare println! calls are anstream's too. statusline.rs was the one file where the guard's file-level granularity could hide a std write on a JSON path, and it no longer does.
Still not approving, unchanged: the diff edits src/commands/remove.rs, src/commands/step/prune.rs, and src/commands/picker/mod.rs, all on the removal path, so per this repo's review policy the merge call is a human's.
`wt remove --format=json` with no branch takes the single-worktree path, and `wt step for-each --format=json` interrupted mid-loop still owes its consumer the results collected so far. Both emissions were uncovered before this branch rewrote their lines, which pulled two pre-existing misses into the patch. Each test fails when its target line is deleted — verified by mutation, since a one-item array is what both the abort and completion paths produce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
One finding on the new remove test, plus a note on the for-each one.
test_remove_json_current_worktree_no_args will fail on Windows. It's the fifth test in remove.rs to run a --foreground removal of the current worktree from inside it, and the other four are all #[cfg_attr(windows, ignore)] with the same reason attached — e.g. on test_remove_main_worktree_vs_linked_worktree: "Tests run as subprocesses which can't change directory via shell integration. Real users are fine — shell integration cds to main before removing. But subprocess tests stay in the worktree, causing Windows file locking errors." That still holds at head: src/ has no set_current_dir anywhere, so the wt process deleting the worktree is cwd'd inside it, and Windows won't unlink a directory that's a live process's cwd.
The background counterpart is the reason the gate isn't blanket: test_remove_from_detached_head_in_worktree also removes the current worktree from within and is ungated, because without --foreground the deletion happens in the detached child. Your test needs --foreground for the JSON to be deterministic, so it lands on the gated side. Suggestion inline.
test (windows) is still pending on this head, so this is from the file's own pattern rather than a CI failure I've watched go red.
Nothing on the for-each test's substance — the SIGTERM path pushes exactly one result before break, so items.len() == 1 is deterministic, and deleting the target print_json leaves stdout empty and fails the parse.
`--foreground` made the new test delete the worktree from inside the `wt` process whose cwd it was, which Windows refuses — "failed to delete …/repo.feature: Permission denied". The default backgrounded removal is what the other current-worktree tests use, and it reports the same JSON: the execution mode picks who deletes, not who reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
The Windows finding is closed, and the fix you took is the better of the two.
Dropping --foreground moves the test to the ungated side of the same contrast the file already draws — test_remove_from_detached_head_in_worktree removes the current worktree from within and runs on Windows, while test_remove_foreground_detached_head carries #[cfg_attr(windows, ignore)] for exactly the cwd-lock reason. Keeping the assertion running on Windows beats skipping it there, which is what my suggestion would have done.
I checked the determinism claim rather than taking the commit message for it, since my last review asserted the opposite. It holds: remove.rs's single-worktree branch calls print_json in the parent, immediately after handle_remove_output returns, and the fate it serializes comes back synchronously — spawn_background_removal runs execute_instant_removal_or_fallback (rename-into-trash plus the branch deletion) in-process and only spawns a detached child for the directory cleanup. The mode changes who unlinks the directory, not who computes or prints the result. test_remove_json_branch_outcome_reflects_execution is the existing proof of the same thing: backgrounded, --format=json, and it asserts branch_outcome down to retained_unmerged. Everything on the current-worktree path that background mode adds — prepare_remove_directory_change, print_switch_message_if_changed — writes to stderr, so stdout stays parseable. test (windows) hasn't started on this head yet, so that's from the code rather than from a green run I watched.
The one open thread left is the for_each.rs marker dir, which was non-blocking and still is. Not approving is unchanged and unrelated to any of the above: the diff edits src/commands/remove.rs, src/commands/step/prune.rs, and src/commands/picker/mod.rs on the removal path, so the merge call stays yours.
Carried over from the sibling test, where the marker count is the assertion. This one asserts on the JSON array instead and never reads the directory, so `kill -TERM $$` on its own says the same thing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Last open thread is closed, and codecov/patch has moved since the arithmetic I handed over.
The for-each simplification holds. I checked the mutation claim from the code rather than the commit message, since the assertion changed shape. Deleting for_each.rs:154 leaves the interrupted branch returning Err(Interrupted) before the completion path's own print_json at line 166 can run, so stdout is empty and serde_json::from_str fails the test — the surviving print_json isn't a fallback for it. And items.len() == 1 stays a real assertion without the marker dir: the standard fixture carries main plus feature-{a,b,c}, noted in the sibling test two above, so a loop that didn't abort would report four.
codecov/patch is green on this head — 100.00% of diff hit, target 98.11%. My 94.87% / two-misses arithmetic was measured on 270d0635a, before the two tests in 54246cb77 landed; both lines it named are now covered, so the waiver conversation is moot and there's nothing to weigh at merge time on that axis.
test (windows) is the only check still running on f25692249; everything else is terminal and green, test (linux), test (macos), code-coverage, and lint included. The Windows question from my earlier review is already answered rather than pending on it: test (windows) went green on d492cc3aa, which is where test_remove_json_current_worktree_no_args moved to the ungated side of the cwd-lock contrast, and f25692249's only change sits inside a #[cfg(unix)] test.
Not approving, unchanged and unrelated to any finding — no review concerns are outstanding. The diff edits src/commands/remove.rs, src/commands/step/prune.rs, and src/commands/picker/mod.rs, all on the removal path, so per this repo's review policy the merge call is yours.
Thirty-six is the post-#3746 total of `print_json` call sites, not the number that had open-coded the two lines. #3746 removed thirty-one `to_string_pretty` lines, one of which was the definition moving out of `src/commands/list/mod.rs`, so thirty call sites were converted; six already called the shared printer. Twenty-six stands as a subset of the thirty.
…an (#3762) Three developer entry points — `--help-page`, `--help-description`, and `--print-schema` — each carried its own copy of a scan that picked the subcommand out of argv by rejecting entries that looked like the binary: ```rust .filter(|a| *a != "--help-page" && !a.starts_with('-') && !a.ends_with("/wt")) .find(|a| !a.contains("target/") && *a != "wt") ``` Neither `ends_with("/wt")` nor `contains("target/")` matches `wt.exe` under a backslash path, so on every Windows install all three read the binary's own path as the command. `--print-schema` exited 2; the other two exited 0 with empty stdout and `Unknown command: C:\...\wt.exe` on stderr. The scan also took the value of a value-carrying global as the command, so `wt -C <path> list --print-schema` answered `No JSON schema for '<path>'`. clap had already parsed the same argv one call earlier. `parse_early_globals` runs the real `Cli` definition with `ignore_errors(true)` and reads `matches.subcommand()` to pick the alias-splice context, and the comment above it already said that exists "so the splice path in `augment_help` has no separate arg scanner". The doc entry points just never asked it. It now carries the name in an `EarlyGlobals` struct, and the three handlers take `Option<&str>` instead of `&[String]`. Error messages are unchanged: `src/cli/mod.rs` has `#[command(external_subcommand)]` for user aliases, so a name the `Cli` definition doesn't declare still arrives intact and can be named back in `Unknown command: X`. `--print-schema` now prints through `crate::output::print_json` rather than `serde_json::to_string_pretty` plus std's `println!`. std's panics with exit 101 when a consumer closes the pipe; anstream's drops the `BrokenPipe`. This was the last JSON-to-stdout surface still panicking after #3746 — the schema landed via #3747 while that PR was in flight, and it lives outside `src/commands/`, where #3746's stdout guard doesn't scan. <details> <summary>Why CI never caught this</summary> `tests/integration_tests/help.rs` and `tests/integration_tests/readme_sync.rs` are both `#![cfg(not(windows))]`, and on Unix the test binary is always named `wt` under `target/`, which is exactly the shape the scan was written against. The new test drives all four cases through a symlink named `wt.exe` — a symlink rather than a copy because the debug binary is 67MB. Verified to fail against the pre-change code. </details> `wt merge --help-page` and `wt merge --help-description` still panic on a closed pipe. Those use std's `println!`/`print!` deliberately, to preserve the ANSI codes anstream's would strip, so they need a different mechanism than this one. Left for a follow-up. > _This was written by Claude Code on behalf of max-sixty_
`wt list | head -3` exited 101 with `failed printing to stdout: Broken pipe`, and so did `wt list statusline | head -1` — the surface a shell prompt runs on every redraw. std's `print!`/`println!` panic on a `BrokenPipe`; anstream's drop it, which is why the `--format=json` surfaces #3746 converted already exit cleanly. Seven stdout surfaces were still on std's macros. Five of them — the `wt list` table, `--version`, `--help-md`, `--help-description`, and `wt config update --print` — are read by a person, so they go through anstream's printer, the canonical color-aware one. ## `wt list` also stops coloring a pipe `wt --help` documents `NO_COLOR` ("Disable colored output") and `CLICOLOR_FORCE` ("Force colored output even when not a TTY"). That second row only means something if color is off when stdout isn't a terminal — and `src/testing/mod.rs` sets `CLICOLOR_FORCE=1` in `STATIC_TEST_ENV_VARS` precisely so ANSI still appears in snapshots. But `wt list` wrote its escapes through std's macros, so it colored a pipe unconditionally and neither variable ever reached it. It now behaves as documented for the piped case: color on a terminal, plain on a pipe, `CLICOLOR_FORCE=1` to keep it on a pipe. `NO_COLOR` is fixed wherever `print_buffered_table` runs, which is the piped case plus `--no-progressive` on a terminal. It does **not** reach the default terminal rendering: `RenderTarget::detect` hands every tty that didn't pass `--no-progressive` to `ProgressiveTable`, which writes to a raw `std::io::stdout()` with each row's escapes already baked in. Closing that last gap means stripping SGR from the progressive rows while leaving the redraw's cursor control and the URL column's OSC 8 intact (`src/styling/hyperlink.rs` states `NO_COLOR` must not affect hyperlink support), so it's a separate change on the most visible surface in the tool. <details> <summary>PTY measurements</summary> On a terminal, with `CLICOLOR_FORCE` unset: | invocation | escapes | |---|---| | `wt list` (progressive, default) | 4590 | | `NO_COLOR=1 wt list` | 4663 — unchanged, the gap above | | `wt list --no-progressive` | 322 | | `NO_COLOR=1 wt list --no-progressive` | 0 | | `NO_COLOR=1 CLICOLOR_FORCE=1 wt list --no-progressive` | 0 — anstream checks `no_color()` first | Piped: 0 escapes by default, 17 under `CLICOLOR_FORCE=1`. Two earlier drafts of this description got this wrong in both directions, and @worktrunk-bot caught each. </details> The five changed snapshots all belong to `BareRepoTest`, whose `configure_wt_cmd` strips `CLICOLOR_FORCE` to capture "the terminal's plain output" and, until now, didn't get it. The diffs remove ANSI and change nothing else — same content, same column widths. ## Two surfaces keep the escapes For the statusline and `--help-page`, the pipe is a courier rather than the destination. A shell prompt or Claude Code captures the statusline and *renders* it; the docs pipeline converts the help page's escapes into HTML spans. Neither consumer is ever a tty, so anstream would strip them every time in production — and no test would catch it, because the suite forces color. They get `crate::output::println_verbatim!`, a sibling of `print_json` that writes the bytes through unchanged. It drops a `BrokenPipe` and still panics on any other write error, matching anstream, so both printers fail the same way on a full disk. Output is byte-identical: the help snapshots and `test_docs_are_in_sync` pass untouched. <details> <summary>Test, and two cleanups that fall out</summary> `test_stdout_surfaces_survive_a_closed_consumer` drives nine invocations across both printers with the child's stdout pipe closed before it is waited on, so the first write has no reader. That's deterministic rather than racing a consumer's exit, and `--version`'s few dozen bytes trip it as readily as the help page's 23KB — `EPIPE` is about whether a reader is attached, not about filling the buffer. Every case was checked to fail against the unfixed code. `statusline.rs` leaves `STDOUT_ALLOWED_PATHS`, since it no longer writes stdout directly; a stray `println!` there fails the guard again. `print_first_buffered_line` is gone. Its one caller is the `WORKTRUNK_FIRST_OUTPUT` benchmark hook, which wrote the same header line through a third path; a LineWriter flushes on the newline that measurement is timing, so the hook is unaffected. </details> > _This was written by Claude Code on behalf of max-sixty_
Both rework features this release already documents, so they fold into the existing bullets rather than adding new ones: #3766 extends #3746's broken-pipe fix to every remaining stdout surface, and #3767 narrows the plugin removal hook's guard from #3754. #3766 also stops `wt list` coloring a pipe, which is user-visible on its own, so that gets its own entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#3771) ## Problem `worktrunk::styling`'s `eprint!` is anstream's and strips ANSI when stderr isn't a terminal; std's prelude macro of the same name keeps it. A file that imports one but not the other — or neither — gets a mix, and adjacent lines of the same message block disagree about whether a redirected stderr carries escapes. `wt list 2>&1 >/dev/null | cat -v` in a repo with a deprecated `[ci]` block, on `main`: ``` ^[[33mM-bM-^VM-2^[[39m ^[[33mProject config: ^[[1m[ci]^[[22m is deprecated in favor of ^[[1m[forge]^[[22m^[[39m M-bM-^FM-3 To see details, run wt config show; to apply updates, run wt config update ``` The warning is `eprint!("{warnings}")` at `src/config/deprecation.rs`, which resolved to std's macro; the hint directly beneath it is the `eprintln!` imported from `styling` four lines later. Anyone redirecting `wt` narration to a file gets escapes on one line and not the next. This is the stderr counterpart of what #3746 and #3766 fixed on stdout, and it went unnoticed for the reason named in `verbatim.rs`'s own docstring: the suite sets `CLICOLOR_FORCE=1`, which forces color on *both* printers, so no snapshot could disagree no matter which macro was in scope. `output_system_guard` doesn't cover it either — it scans for `print!`/`println!` tokens under `src/commands/`, not for which `eprint!` a file imported. ## Solution The rule is now structural rather than per-site. `check_stderr_macros_come_from_styling` in `output_system_guard.rs` walks every `.rs` file under `src/` and flags a bare `eprint!`/`eprintln!` whose file lacks the matching `worktrunk::styling` import. A call satisfies it either way — importing the macro, or qualifying the call as `styling::eprintln!(…)`, which several files (`git/repository/mod.rs`, `config/user/mod.rs`, `commands/config/alias.rs`) already do. Two files are allowlisted with a reason: `testing/mock_stub.rs` relays a stub's captured stderr verbatim, so its bytes are fixture data; `remove_dir.rs`'s one call is a `#[cfg(test)]` skip diagnostic, not narration a user redirects. Reverting the source fixes below makes it name exactly those five lines and nothing else. The sites it fixes: - `src/config/deprecation.rs` — the deprecation warning block above. - `src/commands/config/update.rs` — the `format_update_preview` block shown before `wt config update`'s prompt, reachable with a tty stdin and a redirected stderr. - `src/output/prompt.rs` — the `[y/N/?]` prompt; its blank-line `eprintln!` was already explicitly qualified as `worktrunk::styling::eprintln!`, so the two disagreed within four lines. Both now come from one import. - `src/output/global.rs` — the file the first scan couldn't see, because that scan looked for "imported `eprintln` but not `eprint`" and this file imports neither. Its four styled `eprintln!` calls (`print_outdated_shell_wrapper_hint_once`, `warn_retired_exec_once`, `warn_exec_scrubbed_once`) all resolve to std's, so a user mid-upgrade running `wt … 2>log` gets `ESC[…m` around the shell-wrapper repair hint. The module's own docstring already claimed the contract the code didn't have — *"Regular output still uses `eprintln!`/`println!` directly (from `worktrunk::styling` for color support)"*. One added import makes it true; under the suite's `CLICOLOR_FORCE=1` no snapshot moves. - `src/commands/for_each.rs` — the pre-spawn ANSI reset. `output/handlers.rs` runs the identical three lines (`stderr().flush()?`, `eprint!("{}", anstyle::Reset)`, `stderr().flush().ok()`) immediately before building its `Cmd`, but through anstream's `eprint` *and* anstream's `stderr`; `for_each` used std's for both, so the same operation wrote a literal `ESC[0m` into a redirected stderr where `handlers` dropped it. Both halves move together — the flushes have to name the stream the reset was written to, so switching `eprint!` alone would flush std's handle while anstream's buffer held the write. The `std::io::stderr()` handed to `Stdio::from` four lines down is a different thing and stays. ## Tests `test_stderr_narration_strips_ansi_when_piped` in `output_system_guard.rs`, alongside the closed-consumer test #3766 added. It clears `CLICOLOR_FORCE` and sets `NO_COLOR` (which only anstream honors), triggers the `[ci]` deprecation, and asserts stderr carries the warning and no `\x1b`. Confirmed to fail on the pre-fix source with exactly the escapes quoted above, and to pass with it. That test proves what the property buys at one site; `check_stderr_macros_come_from_styling` is what holds it at all of them. No runtime test can: the suite's `CLICOLOR_FORCE=1` forces color on both printers, so a snapshot agrees whichever macro is in scope, and the property is about every stderr write in the binary rather than any one path. The module docstring is updated for both — the `Allowed:` list no longer reads flatly as "`eprintln!` / `eprint!` (stderr is safe)", which was the sentence someone skims before making this exact mistake. ## Verification `cargo clippy --all-targets`, `cargo fmt --check`, `cargo test --lib --bins` (2,454 passed), and the integration suite (1,961 passed) all run locally. One integration test fails in this sandbox and is unrelated: `test_copy_ignored_preserves_file_executable_permissions` expects `0644` and sees `0664`, because the sandbox's umask is `002` rather than the runner's `022` (confirmed by `umask` → `0002`). It touches none of these files; CI will confirm. --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
worktrunk 0.72.0 Created-by: HarmonybrewBot Commit-by: HarmonybrewBot Merged-by: HarmonybrewBot Description: Created by `brew bump` --- Created with `brew bump-formula-pr`.<details> <summary>release notes</summary> <pre>## Release Notes ### Improved - **A host carrying a forge's name anywhere resolves to that forge again**: 0.71.0 required `github`, `gitlab`, or `gitea` as a whole dot-separated label, which read as an ownership check but wasn't one — an attacker controls their own DNS — while shutting out self-hosters with hyphenated names. `github-enterprise.acme.com`, `mygithub.life-white.uk`, and the `github-personal` SSH alias classify again, so CI status, `wt switch --prs`, and `repo.provider` work with no config. ([#3673](max-sixty/worktrunk#3673)) - **One `[projects."…"]` entry can cover every repository on a host, and can set the forge**: A key containing `*` matches any run of characters, `/` included, so `[projects."git.company.example/*"]` covers a whole host; every matching entry applies, least- to most-specific. The table also gained `forge.platform` and `forge.hostname`, so a self-hosted host needs one entry here rather than a block in every repo. [Docs](https://worktrunk.dev/config/#user-project-specific-settings) ([#3701](max-sixty/worktrunk#3701), thanks @chrishas35 for the request and @witt-bit for the workspace-scoped case it partly serves) - **`wt merge` and `wt step push` leave the target worktree's uncommitted changes in place**: The autostash that held a dirty target's changes restored them as unstaged; both now advance the target with a compare-and-swap `update-ref` and `read-tree -m -u`, which leaves uncommitted work untouched. (Breaking: the fast-forward path no longer runs `git push`, so `pre-push` and the receive-side hooks no longer fire, and a failed sync errors with the ref rolled back.) ([#3703](max-sixty/worktrunk#3703), [#3684](max-sixty/worktrunk#3684), [#3693](max-sixty/worktrunk#3693), thanks @gubasso for reporting) - **Approval state and branch-removal outcomes are machine-readable**: `wt config approvals list --format=json` reports whether a non-interactive run would stop for approval. `wt remove` and `wt step prune` replace `branch_deleted` with `branch_outcome`: `deleted`, `deferred`, `not_attempted`, `retained_unmerged`, `retained_checked_out`, `retained_raced`, `retained_failed`. (Breaking.) ([#3710](max-sixty/worktrunk#3710), thanks @NathanaelRea for the requests) - **Every commit hash worktrunk prints follows `core.abbrev`**: The `wt list` table, `wt switch --prs`'s `log` tab, and `wt config state`'s CI cache table sliced to 8 characters while `--format=json` carried git's `%h`. All now ask git how wide it abbreviates in this repo. ([#3676](max-sixty/worktrunk#3676), [#3677](max-sixty/worktrunk#3677)) - **`wt list --format=json` schema 2 has a published JSON Schema**: [worktrunk.dev/schema/list-v2.json](https://worktrunk.dev/schema/list-v2.json) holds the contract, and `wt list --print-schema` prints the same document. Four fields that were bare strings are now enumerated vocabularies; the emitted JSON is unchanged. ([#3747](max-sixty/worktrunk#3747)) - **A detached worktree is named by its commit, not `-`**: The Branch cell hardcoded `-`, which reads as missing data rather than a state; it now carries the row's abbreviated HEAD, and the picker and statusline name the worktree the same way. ([#3675](max-sixty/worktrunk#3675)) ### Fixed - **Piped output is plain and no longer panics**: `wt list | head -3` exited 101 with `failed printing to stdout: Broken pipe`, and `wt list` wrote ANSI to a pipe unconditionally. Every stdout surface now exits cleanly, and the human-read ones are plain when piped unless `CLICOLOR_FORCE=1`. ([#3746](max-sixty/worktrunk#3746), [#3766](max-sixty/worktrunk#3766)) - **A CI check that hasn't finished no longer reads as passed**: Each forge's status parser missed documented values, so a GitHub PR parked on an approval gate showed green, and GitLab's `canceling` and Azure DevOps's `postponed` read as no CI at all. ([#3741](max-sixty/worktrunk#3741), [#3740](max-sixty/worktrunk#3740)) - **The shell wrappers survive an `rm` alias and a failing `--execute`**: Aliases bake into the wrapper at parse time, so `alias rm='rm -v'` reached its cleanup — noise on zsh and bash, and on nushell an abort that leaked three temp files, as a failing `--execute` body also did. ([#3714](max-sixty/worktrunk#3714), thanks @Ar4l), ([#3732](max-sixty/worktrunk#3732), [#3734](max-sixty/worktrunk#3734)) - **An alias or hook wrapping `wt switch` or `wt remove` keeps your subdirectory**: The user's position came from the `wt` process's cwd, which inside an alias body is the worktree root, so an aliased `wt remove` from `feature/apps/gateway` landed at the primary worktree's root. Fixes [#3723](max-sixty/worktrunk#3723). ([#3724](max-sixty/worktrunk#3724), thanks @vivienm for reporting) - **`wt merge` and `wt step push` refuse a target worktree parked mid-operation**: The two-tree sync refuses an unmerged index but not a stopped cherry-pick or rebase whose conflict was already staged, so the push range could land in a paused target and be committed by `--continue`. ([#3759](max-sixty/worktrunk#3759)) - **The Claude plugin's worktree-remove hook resolves against the worktree path**: The hook anchored at `CLAUDE_PROJECT_DIR`, which the `claude agents` view routinely leaves outside any repository, so `wt remove` died with `not a git repository` and the session became undeletable. Its guard now also requires a `.git` entry. ([#3754](max-sixty/worktrunk#3754), [#3767](max-sixty/worktrunk#3767), thanks @judewang for the fix and the report) - **A Gitea API error is reported as one, not as a parse failure**: `tea api` exits 0 whatever the HTTP status, so both call sites guessed from the body's shape and blamed an API change for an API error. `--include` surfaces the status instead. ([#3713](max-sixty/worktrunk#3713), [#3600](max-sixty/worktrunk#3600)) - **`--print-schema` and the doc-generation help flags name the right command**: All three found the subcommand by scanning `argv` for a `/wt` suffix, which never matches `wt.exe` under a backslash path, so on Windows they read the binary's own path as the command. ([#3762](max-sixty/worktrunk#3762)) - **A multibyte shell name no longer panics**: `extract_filename_from_path` sliced at `len() - 4` to test for `.exe` with no char-boundary check: `SHELL=/bin/日本語 wt config show` panicked, and on macOS every process name goes through it during shell detection. ([#3727](max-sixty/worktrunk#3727)) - **`wt` installed under a dotted name generates shell integration for that name**: `binary_name` used `file_stem`, which cuts at the last dot, so `wt config shell init bash` under `wt.old` emitted a wrapper for `wt`. It now strips only the executable suffix. ([#3719](max-sixty/worktrunk#3719)) - **Concurrent `wt step prune` removals no longer race the worktree registry**: `git worktree remove` reads every sibling under `.git/worktrees/`, so two overlapping removals could have one read a sibling mid-teardown. Registry-mutating removals now serialize behind a second lock. ([#3692](max-sixty/worktrunk#3692)) - **The `wt switch` first-run offer previews the legacy files it removes**: Accepting "Install shell integration?" could delete a deprecated worktrunk-managed wrapper the prompt never named. What gets removed is unchanged. ([#3656](max-sixty/worktrunk#3656)) - **`wt config create --project` writes a resolvable link**: The comment it writes into `.config/wt.toml` carried a raw Zola target, because the link-conversion regex stopped at the first `]` — here the one closing a nested code span. ([#3731](max-sixty/worktrunk#3731)) - **`wt list --branches` counts a local branch containing `/` as local**: The summary tally classified branch-only rows by `branch.contains('/')`, so a local `feature/login` counted under "N remote branches". ([#3687](max-sixty/worktrunk#3687)) - **`wt step relocate`'s human summary counts template-error branches as skipped**: `--format=json` already folded them into `skipped`; the human tally undercounted by the number of branches whose `worktree-path` template failed to expand. ([#3688](max-sixty/worktrunk#3688)) ### Documentation - **SignPath attribution appears with the artifacts it describes**: The code-signing notice and a route to the policy now sit in the install section's Windows block on the README and the docs landing page, as SignPath Foundation's OSS program requires. ([#3709](max-sixty/worktrunk#3709)) - **`wt step copy-ignored`'s `--require-include` example renders as a terminal block**: It was the only `console` block in the command's long help missing the `$ ` prefix. ([#3706](max-sixty/worktrunk#3706)) ### Internal - **Library API rework** (Breaking library API): `cargo-semver-checks` fails five lints — `LegacyForgeAlias` and `Repository::legacy_forge_alias` removed with the forge-classification revert, `Repository::forge_platform_override` removed for one shared resolver, `stage_worktree_removal` gained two parameters, `UserProjectOverrides` gained a `forge` field, and the `real-repo-benches` feature was removed. ([#3673](max-sixty/worktrunk#3673), [#3694](htt See merge request: Harmonybrew/homebrew-core!16275
print_json— the pretty-JSON printer behind--format=json— lived insrc/commands/list/mod.rs, under the one command that happened to need it first, whilewt list statuslineandwt config approvals listreached across for it. It now lives insrc/output/json.rs, re-exported ascrate::output::print_json.Moving it surfaced thirty other call sites that had each open-coded the same two lines, and that those two lines were not equivalent everywhere. Four printed through anstream's
println!(viaworktrunk::styling), the other twenty-six through std's. anstream drops aBrokenPipewrite error; std panics. So on main today:while
wt config show --format=json | head -3exits cleanly — same command family, different behavior, decided by which macro happened to be imported in that file.EPIPEdepends on whether a reader is still attached, not on how much is written, so this is a race rather than a size threshold: an output that fits the pipe buffer usually lands before the consumer goes away, which is why it stayed hidden rather than why it is safe. A three-byte write panics just as readily once the reader has closed.All thirty-six call sites now go through
print_json, andprint_jsonprints through anstream, so no--format=jsonsurface panics on a closed pipe. The command above now exits 0 with an empty stderr; also checked by hand onwt list,wt step prune --dry-run,wt config show,wt step eval,wt config approvals list, andwt list statusline.wt switch --format=jsonis the one surface that isn't aprint_jsoncaller: it emits its single result as one compact line, which is what a shell loop reads, so converting it would change a machine-readable format for no gain. It gets the same anstream printer instead, which is the part that matters here.wt list statusline --format=jsonhad a third instance hiding in its schema-1 empty path, a bareprintln!("[]")on std's macro — on the surface that runs on every prompt redraw. It now serializes an empty array throughprint_json, which emits the same two bytes;test_statusline_json_outside_worktreealready asserted them. A module-levelprintlnimport would have been wrong there, since the text statusline'sprintln!deliberately bypasses anstream so its pre-rendered ANSI survives a non-tty stdout.Pruned eight now-dead entries from
STDOUT_ALLOWED_PATHSintests/integration_tests/output_system_guard.rs. Centralizing these writes left seven allowlisted files with no directprintln!at all, so their exemptions covered no code and the guard quietly stopped protecting exactly the files this branch touched; a strayprintln!inmerge.rsorremove.rsfails the test again. (alias.rs's entry was already dead.)Serialization is otherwise byte-identical: serde escapes control characters, so raw ANSI never reaches this output and anstream's stripping is a no-op on it.
src/commands/CLAUDE.mdgains one line under "Adding a CLI Command" stating the rule — stdout through anstream,print_jsonas the printer, switch's compact line as the one shape difference — so the next JSON surface doesn't open-code it again.Two
print_jsoncalls the diff rewrote were uncovered on the base commit, which pulled their misses intocodecov/patchwithout any behavior changing. Rather than hand over a waiver, both now have tests:wt remove --format=jsonwith no branch argument (the single-worktree path, distinct from the named removals already tested) andwt step for-each --format=jsoninterrupted mid-loop, which still owes its consumer the results collected before the signal. Each was mutation-checked — deleting the line under test fails it — because a one-item array is what both the abort and completion paths produce, so a weaker assertion would pass either way.