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");
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,
));
});
}
#[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}",
);
}
#[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);
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"
);
}
}
#[rstest]
#[cfg(unix)]
fn test_for_each_aborts_on_signal_exit(repo: TestRepo) {
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");
assert_eq!(
output.status.code(),
Some(143),
"expected exit 143 (SIGTERM), got {:?}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr),
);
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}"
);
}
#[rstest]
fn test_for_each_skips_prunable_worktrees(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature");
std::fs::remove_dir_all(&worktree_path).unwrap();
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"
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["for-each", "--", "echo", "Running in {{ branch }}"],
None,
));
}
#[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());
}
assert!(
items.iter().any(|i| i["branch"] == "feature"),
"feature branch should be in results"
);
}
#[rstest]
fn test_for_each_commit_matches_per_worktree_head(repo: TestRepo) {
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(),
);
}
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)
);
for (branch, sha) in &expected {
let needle = format!("{branch} {sha}");
assert!(
combined.contains(&needle),
"expected {branch}'s {{{{ commit }}}} = {sha} in output\noutput={combined}",
);
}
}
#[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)
);
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);
assert_eq!(item["error"], "exit status: 1");
}
}