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");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["for-each", "--", "nonexistent-command-12345", "--some-arg"],
None,
));
}
#[rstest]
#[cfg(unix)]
fn test_for_each_json_spawn_failure(repo: TestRepo) {
use std::path::PathBuf;
let git_path: PathBuf = std::env::var_os("PATH")
.iter()
.flat_map(std::env::split_paths)
.map(|p| p.join("git"))
.find(|p| p.is_file())
.expect("git must be in PATH for tests");
let tmp = tempfile::tempdir().expect("create tmpdir for minimal PATH");
std::os::unix::fs::symlink(&git_path, tmp.path().join("git"))
.expect("symlink git into minimal PATH");
let mut cmd = repo.wt_command();
cmd.env("PATH", tmp.path());
cmd.args(["step", "for-each", "--format=json", "--", "true"]);
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 touch_cmd = format!("touch {marker_path}/$(basename \"$(pwd)\")");
let output = repo
.wt_command()
.args([
"step", "for-each", "--", &touch_cmd, "&&", "kill", "-TERM", "$$",
])
.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_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");
}
}