Files
max-sixty__worktrunk/tests/integration_tests/for_each.rs
Maximilian Roos 860fa02ecf fix(output): don't panic when a --format=json consumer closes the pipe (#3746)
`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:

```console
$ 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_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:03:14 -07:00

553 lines
18 KiB
Rust

//! Integration tests for `wt step for-each`
use crate::common::{TestRepo, make_snapshot_cmd, repo};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
#[rstest]
fn test_for_each_single_worktree(repo: TestRepo) {
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["for-each", "--", "git", "status", "--short"],
None,
));
}
#[rstest]
fn test_for_each_multiple_worktrees(mut repo: TestRepo) {
repo.add_worktree("feature-a");
repo.add_worktree("feature-b");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["for-each", "--", "git", "branch", "--show-current"],
None,
));
}
#[rstest]
fn test_for_each_command_fails_in_one(mut repo: TestRepo) {
repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["for-each", "--", "git", "show", "nonexistent-ref"],
None,
));
}
#[rstest]
fn test_for_each_no_args_error(repo: TestRepo) {
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["for-each"], None));
}
#[rstest]
fn test_for_each_with_detached_head(mut repo: TestRepo) {
repo.add_worktree("detached-test");
repo.detach_head_in_worktree("detached-test");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["for-each", "--", "git", "status", "--short"],
None,
));
}
#[rstest]
fn test_for_each_with_template(repo: TestRepo) {
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["for-each", "--", "echo", "Branch: {{ branch }}"],
None,
));
}
#[rstest]
fn test_for_each_detached_branch_variable(mut repo: TestRepo) {
repo.add_worktree("detached-test");
repo.detach_head_in_worktree("detached-test");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["for-each", "--", "echo", "Branch: {{ branch }}"],
None,
));
}
#[rstest]
fn test_for_each_spawn_fails(mut repo: TestRepo) {
repo.add_worktree("feature");
// Normalize platform-specific spawn-error text so the snapshot is
// identical on Unix (`No such file or directory (os error 2)`) and
// Windows (`program not found`).
insta::with_settings!({
filters => vec![
(r"No such file or directory \(os error \d+\)", "[SPAWN_FAIL]"),
(r"program not found", "[SPAWN_FAIL]"),
],
}, {
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["for-each", "--", "nonexistent-command-12345", "--some-arg"],
None,
));
});
}
/// argv boundaries from the post-`--` args reach the program intact. The
/// command is exec'd directly — no implicit shell — so each argv element
/// arrives at the child as a single argument regardless of its content.
/// See issue #2461 for the legacy bug where joining argv into `sh -c`
/// collapsed `python3 -c 'import sys; print(sys.argv[1:])' 'a b'` into a
/// shell syntax error.
#[rstest]
#[cfg(unix)]
fn test_for_each_preserves_argv_quoting(repo: TestRepo) {
let output = repo
.wt_command()
.args([
"step",
"for-each",
"--",
"python3",
"-c",
"import sys; print(sys.argv[1:])",
"a b",
])
.output()
.expect("run wt step for-each");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
assert!(
output.status.success(),
"for-each should succeed when argv quoting is preserved: {combined}",
);
assert!(
combined.contains("['a b']"),
"expected python to receive 'a b' as a single argv element, got: {combined}",
);
assert!(
!combined.contains("Syntax error"),
"for-each should not produce shell syntax errors when argv has quoted args: {combined}",
);
}
/// Spawn failure (program not found) is the one branch in the failure
/// handler that does NOT downcast to `WorktrunkError::ChildProcessExited`,
/// and it appears in JSON mode as `exit_code: null`. Without this test the
/// spawn-failed JSON path is unreachable from the integration suite
/// (#2089 review).
#[rstest]
#[cfg(unix)]
fn test_for_each_json_spawn_failure(repo: TestRepo) {
let mut cmd = repo.wt_command();
cmd.args([
"step",
"for-each",
"--format=json",
"--",
"nonexistent-command-12345",
]);
let output = cmd.output().unwrap();
assert!(
!output.status.success(),
"for-each should fail when shell spawn fails: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!(
"for-each --format=json should emit valid JSON on spawn failure: {e}\nstdout: {stdout}"
)
});
let items = json.as_array().expect("JSON output should be an array");
assert!(!items.is_empty(), "expected at least one worktree result");
for item in items {
assert_eq!(item["success"], false);
// Spawn failure ⇒ no exit code (vs. exit-code path which uses an integer)
assert!(
item["exit_code"].is_null(),
"spawn failure should report exit_code: null, got {item}"
);
let error = item["error"]
.as_str()
.expect("error field should be a string");
assert!(
!error.is_empty(),
"spawn failure error message should be non-empty"
);
}
}
/// Signal-derived exit (Ctrl-C, SIGTERM) in a child must abort the loop
/// rather than continuing into the remaining worktrees. Simulated here with
/// a command that self-signals via SIGTERM — this drives the same
/// `ChildProcessExited { signal: Some(_), .. }` path as a real Ctrl-C against
/// the wt process. Sending SIGINT to the parent wt process from an integration
/// test is impractical (it would kill the test harness), so we cover the
/// signal-detection branch via an equivalent in-child signal.
#[rstest]
#[cfg(unix)]
fn test_for_each_aborts_on_signal_exit(repo: TestRepo) {
// The standard fixture already includes main + feature-{a,b,c} worktrees,
// so we just need the command to abort on the first visit.
// A marker file per visited worktree lets us assert that the loop stopped
// after the first signal. for-each exec's argv directly with no implicit
// shell, so shell features (`&&`, `$$`, `$(...)`) need an explicit
// `sh -c` invocation.
let marker_dir = tempfile::tempdir().expect("create marker tmpdir");
let marker_path = marker_dir.path().to_string_lossy().to_string();
let shell_cmd = format!("touch {marker_path}/$(basename \"$(pwd)\") && kill -TERM $$");
let output = repo
.wt_command()
.args(["step", "for-each", "--", "sh", "-c", &shell_cmd])
.output()
.expect("run wt step for-each");
// Exit code: 128 + SIGTERM (15) = 143
assert_eq!(
output.status.code(),
Some(143),
"expected exit 143 (SIGTERM), got {:?}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr),
);
// Exactly one marker file should exist — the remaining worktrees must
// not have been visited after the signal aborted the loop.
let markers: Vec<_> = std::fs::read_dir(marker_dir.path())
.expect("read marker dir")
.filter_map(Result::ok)
.collect();
assert_eq!(
markers.len(),
1,
"expected exactly one worktree visited before abort, got {}: stderr={}",
markers.len(),
String::from_utf8_lossy(&output.stderr),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Interrupted"),
"expected 'Interrupted' message in stderr, got: {stderr}"
);
}
/// An interrupted `--format=json` run still owes its consumer the results it
/// collected before the signal: the machine-readable answer is emitted on the
/// abort path too, not just on the completing one. Same in-child SIGTERM as
/// the test above, for the same reason.
#[rstest]
#[cfg(unix)]
fn test_for_each_json_emitted_on_signal_abort(repo: TestRepo) {
let output = repo
.wt_command()
.args([
"step",
"for-each",
"--format=json",
"--",
"sh",
"-c",
"kill -TERM $$",
])
.output()
.expect("run wt step for-each");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.code(),
Some(143),
"expected exit 143 (SIGTERM), got {:?}\nstderr: {stderr}",
output.status.code(),
);
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("interrupted run must still emit JSON: {e}\nstdout: {stdout}"));
let items = json.as_array().expect("results are an array");
assert_eq!(
items.len(),
1,
"only the worktree visited before the signal is reported:\n{stdout}"
);
}
/// for-each relocates the user's command into each worktree, so inherited
/// git-discovery vars (`GIT_DIR`/`GIT_WORK_TREE`) must be scrubbed — git
/// resolves them before walking up from the cwd, so a forwarded value would
/// misdirect every iteration's `git` calls to the one inherited repo
/// (issue #3373; same contract as the hook tests in `user_hooks.rs`).
///
/// Sets a discovery context consistent with the repo wt operates on (as a
/// `!wt` git alias would), so wt itself runs normally; each visited worktree
/// records the `GIT_DIR`/`GIT_WORK_TREE` it sees — `[][]` once scrubbed.
#[rstest]
#[cfg(unix)]
fn test_for_each_does_not_inherit_git_discovery_vars(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let marker_dir = tempfile::tempdir().expect("create marker tmpdir");
let marker_path = marker_dir.path().to_string_lossy().to_string();
let shell_cmd = format!(
r#"printf '[%s][%s]' "$GIT_DIR" "$GIT_WORK_TREE" > {marker_path}/$(basename "$(pwd)")"#
);
let output = repo
.wt_command()
.args(["step", "for-each", "--", "sh", "-c", &shell_cmd])
.env("GIT_DIR", repo.root_path().join(".git"))
.env("GIT_WORK_TREE", repo.root_path())
.output()
.expect("run wt step for-each");
assert!(
output.status.success(),
"for-each run failed: {}",
String::from_utf8_lossy(&output.stderr)
);
// Both the primary worktree and the added one must have been visited and
// seen a scrubbed environment; misdirection is only observable when the
// command runs in a worktree other than the inherited context's.
for dir in [repo.root_path(), feature_path.as_path()] {
let marker = marker_dir
.path()
.join(dir.file_name().expect("worktree dir has a name"));
let seen = std::fs::read_to_string(&marker)
.unwrap_or_else(|e| panic!("marker for {} missing: {e}", dir.display()));
assert_eq!(
seen,
"[][]",
"for-each child in {} inherited git-discovery vars ([GIT_DIR][GIT_WORK_TREE]): {seen}",
dir.display()
);
}
}
#[rstest]
fn test_for_each_skips_prunable_worktrees(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature");
// Delete the worktree directory to make it prunable
std::fs::remove_dir_all(&worktree_path).unwrap();
// Verify git sees it as prunable
let output = repo
.git_command()
.args(["worktree", "list", "--porcelain"])
.run()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("prunable"),
"Expected worktree to be prunable after deleting directory"
);
// wt step for-each should skip the prunable worktree and complete without errors
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["for-each", "--", "echo", "Running in {{ branch }}"],
None,
));
}
// ============================================================================
// --format=json
// ============================================================================
#[rstest]
fn test_for_each_json(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("feature");
let output = repo
.wt_command()
.args(["step", "for-each", "--format=json", "--", "true"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
let items = json.as_array().unwrap();
assert!(items.len() >= 2, "expected at least 2 worktrees");
for item in items {
assert_eq!(item["success"], true);
assert_eq!(item["exit_code"], 0);
assert!(item["path"].as_str().is_some());
}
// feature worktree should be in results
assert!(
items.iter().any(|i| i["branch"] == "feature"),
"feature branch should be in results"
);
}
/// `{{ commit }}` must resolve per-worktree when iterating across worktrees
/// whose branches differ from the running worktree's branch. This exercises
/// the `rev-parse <branch>` fallback in `build_hook_context` — the on-branch
/// cache-reuse path would return the main worktree's SHA for every iteration.
#[rstest]
fn test_for_each_commit_matches_per_worktree_head(repo: TestRepo) {
// Give each fixture feature worktree a distinct HEAD so a buggy cache
// reuse (same SHA everywhere) fails visibly.
let mut expected = std::collections::HashMap::new();
expected.insert("main".to_string(), repo.git_output(&["rev-parse", "HEAD"]));
for branch in ["feature-a", "feature-b", "feature-c"] {
let wt_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("repo.{branch}"));
repo.run_git_in(
&wt_path,
&["commit", "--allow-empty", "-m", &format!("{branch} tip")],
);
let sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.current_dir(&wt_path)
.run()
.unwrap();
expected.insert(
branch.to_string(),
String::from_utf8_lossy(&sha.stdout).trim().to_owned(),
);
}
// echo both fields so we can match each SHA to its branch.
let output = repo
.wt_command()
.args([
"step",
"for-each",
"--",
"echo",
"{{ branch }} {{ commit }}",
])
.output()
.expect("run wt step for-each");
assert!(
output.status.success(),
"for-each failed: stderr={}",
String::from_utf8_lossy(&output.stderr),
);
// for-each pipes foreground output through wt's styling layer, which may
// wrap lines in ANSI codes. Substring-match `branch sha` anywhere in the
// combined output — each worktree's echo contributes exactly one such
// pairing, so a mis-resolved SHA (e.g., main's SHA appearing after
// feature-b's branch label) is still caught.
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
for (branch, sha) in &expected {
let needle = format!("{branch} {sha}");
assert!(
combined.contains(&needle),
"expected {branch}'s {{{{ commit }}}} = {sha} in output\noutput={combined}",
);
}
}
/// `{{ commit }}` for a sibling worktree on detached HEAD must resolve to that
/// worktree's HEAD — not the running worktree's HEAD. The cache key for
/// `WorkingTree::head_sha` is the worktree path, and HEAD is per-worktree, so
/// reading via `repo.current_worktree()` in a detached sibling would return
/// the main worktree's SHA. Build a divergence: advance `feature-b` past the
/// shared tip, then detach it at that new SHA so its HEAD differs from main.
#[rstest]
fn test_for_each_commit_detached_sibling_matches_per_worktree_head(repo: TestRepo) {
let feature_b_path = repo.worktree_path("feature-b").to_path_buf();
repo.run_git_in(
&feature_b_path,
&["commit", "--allow-empty", "-m", "feature-b tip"],
);
repo.detach_head_in_worktree("feature-b");
let main_sha = repo.git_output(&["rev-parse", "HEAD"]);
let feature_b_sha = repo.head_sha_in(&feature_b_path);
assert_ne!(
main_sha, feature_b_sha,
"detached sibling must have a distinct HEAD so a buggy fallback to the running worktree's SHA is visible",
);
let output = repo
.wt_command()
.args([
"step",
"for-each",
"--",
"echo",
"{{ branch }} {{ commit }}",
])
.output()
.expect("run wt step for-each");
assert!(
output.status.success(),
"for-each failed: stderr={}",
String::from_utf8_lossy(&output.stderr),
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
// Detached HEAD surfaces as `{{ branch }} == "HEAD"`. The sibling's commit
// must be its own HEAD, not the running worktree's.
let needle = format!("HEAD {feature_b_sha}");
assert!(
combined.contains(&needle),
"expected detached sibling's {{{{ commit }}}} = {feature_b_sha} in output\noutput={combined}",
);
assert!(
!combined.contains(&format!("HEAD {main_sha}")),
"detached sibling's {{{{ commit }}}} must not resolve to main's SHA {main_sha}\noutput={combined}",
);
}
#[rstest]
fn test_for_each_json_with_failure(repo: TestRepo) {
repo.commit("initial");
let output = repo
.wt_command()
.args(["step", "for-each", "--format=json", "--", "false"])
.output()
.unwrap();
assert!(!output.status.success());
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
let items = json.as_array().unwrap();
assert!(!items.is_empty());
for item in items {
assert_eq!(item["success"], false);
assert_eq!(item["exit_code"], 1);
// error field contains the raw message from the child process
assert_eq!(item["error"], "exit status: 1");
}
}