Skip to content

fix(output): don't panic when a --format=json consumer closes the pipe - #3746

Merged
max-sixty merged 7 commits into
mainfrom
move-print-json
Aug 6, 2026
Merged

max-sixty merged 7 commits into
mainfrom
move-print-json

Conversation

@max-sixty

@max-sixty max-sixty commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner

print_json — the pretty-JSON printer behind --format=json — lived in src/commands/list/mod.rs, under the one command that happened to need it first, while wt list statusline and wt config approvals list reached across for it. It now lives in src/output/json.rs, re-exported as crate::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! (via worktrunk::styling), the other twenty-six through std's. anstream drops a BrokenPipe write error; std panics. So on main today:

$ wt config state get --format=json | head -3
{
  "ci_status": [
    {
thread 'main' panicked at library/std/src/io/stdio.rs:1165:9:
failed printing to stdout: Broken pipe (os error 32)

while wt config show --format=json | head -3 exits cleanly — same command family, different behavior, decided by which macro happened to be imported in that file. EPIPE depends 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, and print_json prints through anstream, so no --format=json surface panics on a closed pipe. The command above now exits 0 with an empty stderr; also checked by hand on wt list, wt step prune --dry-run, wt config show, wt step eval, wt config approvals list, and wt list statusline.

wt switch --format=json is the one surface that isn't a print_json caller: 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=json had a third instance hiding in its schema-1 empty path, a bare println!("[]") on std's macro — on the surface that runs on every prompt redraw. It now serializes an empty array through print_json, which emits the same two bytes; test_statusline_json_outside_worktree already asserted them. A module-level println import would have been wrong there, since the text statusline's println! deliberately bypasses anstream so its pre-rendered ANSI survives a non-tty stdout.

Pruned eight now-dead entries from STDOUT_ALLOWED_PATHS in tests/integration_tests/output_system_guard.rs. Centralizing these writes left seven allowlisted files with no direct println! at all, so their exemptions covered no code and the guard quietly stopped protecting exactly the files this branch touched; a stray println! in merge.rs or remove.rs fails 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.md gains one line under "Adding a CLI Command" stating the rule — stdout through anstream, print_json as the printer, switch's compact line as the one shape difference — so the next JSON surface doesn't open-code it again.

Two print_json calls the diff rewrote were uncovered on the base commit, which pulled their misses into codecov/patch without any behavior changing. Rather than hand over a waiver, both now have tests: wt remove --format=json with no branch argument (the single-worktree path, distinct from the named removals already tested) and wt step for-each --format=json interrupted 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.

This was written by Claude Code on behalf of max-sixty

max-sixty and others added 2 commits August 5, 2026 14:58
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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/CLAUDE.md Resolved
…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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@max-sixty

Copy link
Copy Markdown
Owner Author

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 alias.rs. Pruned all eight, including the pre-existing one. The prune is the enforcement, so I skipped the extra positive rule: with merge.rs, remove.rs, for_each.rs, config/show.rs, step/relocate.rs, picker/mod.rs, and list/mod.rs off the allowlist, a reappearing println!("{}", serde_json::to_string_pretty(…)?) in any of them fails check_no_stdout_in_commands on the println! alone — a second check keyed on to_string_pretty would be a second mechanism for the same guarantee. It would still add something for files allowlisted on other grounds (config/state.rs, step/prune.rs, step/copy_ignored.rs), which is worth doing separately if you think that gap is live.

emit_switch_json. Confirmed: serde_json::to_string plus std's println!, with no println in switch.rs's worktrunk::styling import. I took neither branch of your suggestion exactly. Converting it to print_json turns a machine-readable one-liner into pretty JSON, which is a format change for consumers that read a line — the compact shape is the right one for a command reporting a single result. But the caveat alone would leave the panic shape in place, and unreachable-in-practice isn't the same as unreachable: it's a race with the reader, not a function of payload size.

So switch.rs now imports anstream's println! and keeps its compact line. That closes the panic on the last surface without touching the format, and it makes the shape the only thing switch does differently.

The doc line reads as the rule with switch falling out of it, rather than as an exception to it:

A --format=json mode prints its answer to stdout through anstream's println!, which drops a BrokenPipe write error where std's panics. crate::output::print_json is that printer for every surface but wt switch --format=json, which emits its single result as one compact line.

Agreed on the broken-pipe integration test, and thanks for the cfg!(test) detail — anstream delegating to ::std::println! under cfg!(test) means an in-process test asserts the opposite of the fix. Not adding one.

On the removal-path files: remove.rs, step/prune.rs, and picker/mod.rs are edited only where they serialize their JSON answer, and the guard prune is a test-file change. Flagging the human merge call to the repo owner along with the codecov/patch arithmetic — its two misses are pre-existing lines this diff relocated.

This was written by Claude Code on behalf of max-sixty

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Both first-round findings check out at 270d063 — eight allowlist entries gone, and switch.rs's worktrunk::styling import now carries println, so its compact line goes through anstream. Agreed on skipping the to_string_pretty check for the pruned files; the prune is the stronger enforcement there.

Two things your replies don't cover, both from the second review (posted 00:31, ~30 min before them) rather than the first.

statusline.rs still has one. println!("[]") on the schema-1 branch of run_json's print_empty — statusline.rs's styling import brings in fix_dim_after_color_reset, terminal_width_for_statusline, and truncate_visible, no println, so that's std's. I swept src/commands/ at head: it's the only --format=json write left on std's macro. Payload size doesn't save it — EPIPE turns on whether a reader is still attached, not on how much you write.

The fix wants to be one qualified call rather than a module-level import, since line 848's println!("{}", output) deliberately bypasses anstream so its pre-rendered ANSI survives a non-tty stdout:

worktrunk::styling::println!("[]");

That also makes the new CLAUDE.md line true as written — as it stands, the printer rule isn't absolute and wt switch isn't the only exception. Happy to push it if you'd rather not.

On the gap you asked about (config/state.rs, step/prune.rs, step/copy_ignored.rs — allowlisted on other grounds): it's live, and statusline.rs is the instance. A file allowlisted for its text stdout can grow a JSON write that nothing checks, which is what happened there. A to_string_pretty-keyed rule wouldn't have caught this particular one, though, since the payload is a literal "[]" — so if you do add a check later, keying it on the printer rather than the serializer is what would have flagged it.

codecov/patch. Your read is right; here's the arithmetic. Both misses are lines this diff rewrote in place and that were already uncovered on bdd7113c9.

Verified against the Codecov compare API

GET /api/v2/github/max-sixty/repos/worktrunk/compare/?base=bdd7113c95af199346feb2c02643ad051d880c03&head=270d0635a239e852a22de2dd74f8202db44e7386

Summing .files[] | select(.has_diff) | .totals.patch: 39 patch lines, 37 hits, 2 misses — 94.87%. The two:

head line base line base coverage
src/commands/remove.rs 368 print_json(&json)?; 368 (println!("{}", serde_json::to_string_pretty(&json)?)) miss
src/commands/for_each.rs 154 print_json(&json_results)?; 153 (same shape, the interrupted branch) miss

Base coverage from file_report/<path>?sha=bdd7113c9… — line_coverage has remove.rs:368 and for_each.rs:153 as misses on the merge-base. for_each.rs shifts by one because the use crate::output::print_json; line is an addition; remove.rs doesn't because its import line was a replacement.

The remaining sixteen files with patch lines are all 100%.

Unchanged on approval: the diff touches remove.rs, step/prune.rs, and picker/mod.rs on the removal path, so the merge call is yours either way.

`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>
@max-sixty

Copy link
Copy Markdown
Owner Author

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 EPIPE is possible.

I took a different fix from the one you suggested. Rather than worktrunk::styling::println!("[]"), the schema-1 empty path now goes through print_json(&serde_json::json!([])). to_string_pretty renders an empty array as the same two bytes, so the output is unchanged — test_statusline_json_outside_worktree's assert_eq!(output.trim(), "[]") passes untouched, which is what proves it rather than my reading of serde's formatter.

The reason to prefer it: your version leaves wt list statusline --format=json as a second surface the src/commands/CLAUDE.md line has to carve out, and you're right that the line was false as written. Routing it through print_json removes the exception instead of documenting it, so the line is true again with wt switch --format=json's compact shape as the only difference.

Your caveat about the module-level import was exactly right and is why this is a call-site change either way: statusline.rs:848 bypasses anstream deliberately, documented at line 726, and importing println would have silently rerouted the text statusline's pre-rendered ANSI.

src/commands/statusline.rs now has one println! left, the text one at 848, and it is not on a --format=json path.

Understood on the removal-path policy — I'm taking the merge call to the repo owner along with the codecov/patch arithmetic, not waiting on an approval from you.

This was written by Claude Code on behalf of max-sixty

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/integration_tests/remove.rs Resolved
Comment thread tests/integration_tests/for_each.rs Resolved
`--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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@max-sixty
max-sixty merged commit 860fa02 into main Aug 6, 2026
43 checks passed
@max-sixty
max-sixty deleted the move-print-json branch August 6, 2026 06:03
worktrunk-bot added a commit that referenced this pull request Aug 6, 2026
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.
max-sixty added a commit that referenced this pull request Aug 7, 2026
…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_
max-sixty added a commit that referenced this pull request Aug 7, 2026
`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_
max-sixty added a commit that referenced this pull request Aug 7, 2026
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>
max-sixty pushed a commit that referenced this pull request Aug 9, 2026
…#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>
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
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

This branch was previously deployed

1 inactive deployment
tend — f2569224 Deployed Aug 6, 2026 by max-sixty via review #3724
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants