use std::fs;
use std::path::Path;
use std::process::Output;
use crate::common::{
run_rsmultigit_with_env, run_rsmultigit_with_stdin, setup_git_repos, stderr_str, stdout_str,
write_config,
};
fn run(tmp: &Path, config: &Path, args: &[&str]) -> Output {
let cfg_str = config.to_string_lossy().to_string();
run_rsmultigit_with_env(tmp, args, &[("RSMULTIGIT_CONFIG", &cfg_str)])
}
fn run_with_stdin(tmp: &Path, config: &Path, args: &[&str], stdin_bytes: &[u8]) -> Output {
let cfg_str = config.to_string_lossy().to_string();
run_rsmultigit_with_stdin(tmp, args, &[("RSMULTIGIT_CONFIG", &cfg_str)], stdin_bytes)
}
#[test]
fn check_same_all_identical_is_silent() {
let tmp = setup_git_repos(&["a", "b", "c"]);
for repo in ["a", "b", "c"] {
fs::write(tmp.path().join(repo).join(".gitignore"), "target\n").unwrap();
}
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same"]);
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
assert_eq!(stdout_str(&output), "");
}
#[test]
fn check_same_divergent_exits_nonzero() {
let tmp = setup_git_repos(&["a", "b", "c"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("c/.gitignore"), "y\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same"]);
assert!(!output.status.success());
let stdout = stdout_str(&output);
assert!(stdout.contains("[gi]"), "stdout: {stdout}");
assert!(stdout.contains("3 files, 2 groups"), "stdout: {stdout}");
assert!(stdout.contains("group A (2 files)"), "stdout: {stdout}");
assert!(stdout.contains("group B (1 files)"), "stdout: {stdout}");
}
#[test]
fn check_same_terse_prints_only_rule_name() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "y\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--terse"]);
assert!(!output.status.success());
assert_eq!(stdout_str(&output), "gi");
}
#[test]
fn check_same_no_header_suppresses_rule_label() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "y\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--no-header"]);
assert!(!output.status.success());
let stdout = stdout_str(&output);
assert!(
!stdout.contains("[gi]"),
"stdout should not contain [gi]: {stdout}"
);
assert!(stdout.contains("2 files, 2 groups"), "stdout: {stdout}");
}
#[test]
fn check_same_only_runs_named_rule() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "y\n").unwrap();
fs::write(tmp.path().join("a/README"), "same\n").unwrap();
fs::write(tmp.path().join("b/README"), "same\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
[[check]]
name = "readme"
select = "*"
path = "README"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--checks", "readme"]);
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
assert_eq!(stdout_str(&output), "");
}
#[test]
fn check_same_checks_accepts_multiple_names() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "y\n").unwrap();
fs::write(tmp.path().join("a/README"), "one\n").unwrap();
fs::write(tmp.path().join("b/README"), "two\n").unwrap();
fs::write(tmp.path().join("a/LICENSE"), "MIT\n").unwrap();
fs::write(tmp.path().join("b/LICENSE"), "BSD\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
[[check]]
name = "readme"
select = "*"
path = "README"
[[check]]
name = "license"
select = "*"
path = "LICENSE"
"#,
);
let output = run(
tmp.path(),
&cfg,
&["check-same", "--checks", "gi", "license"],
);
assert!(!output.status.success());
let stdout = stdout_str(&output);
assert!(stdout.contains("[gi]"), "stdout: {stdout}");
assert!(stdout.contains("[license]"), "stdout: {stdout}");
assert!(
!stdout.contains("[readme]"),
"readme should be skipped: {stdout}"
);
}
#[test]
fn check_same_checks_preserves_request_order() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "y\n").unwrap();
fs::write(tmp.path().join("a/README"), "one\n").unwrap();
fs::write(tmp.path().join("b/README"), "two\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
[[check]]
name = "readme"
select = "*"
path = "README"
"#,
);
let output = run(
tmp.path(),
&cfg,
&["check-same", "--checks", "readme", "gi"],
);
assert!(!output.status.success());
let stdout = stdout_str(&output);
let readme_pos = stdout.find("[readme]").expect("missing [readme]");
let gi_pos = stdout.find("[gi]").expect("missing [gi]");
assert!(readme_pos < gi_pos, "expected readme before gi: {stdout}");
}
#[test]
fn check_same_unknown_rule_fails() {
let tmp = setup_git_repos(&["a"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--checks", "nonexistent"]);
assert!(!output.status.success());
let stderr = stderr_str(&output);
assert!(
stderr.contains("nonexistent") && stderr.contains("unknown check name"),
"stderr: {stderr}"
);
}
#[test]
fn check_same_mixed_known_and_unknown_names_fails() {
let tmp = setup_git_repos(&["a"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--checks", "gi", "bogus"]);
assert!(!output.status.success());
let stderr = stderr_str(&output);
assert!(
stderr.contains("bogus"),
"stderr should mention bogus: {stderr}"
);
}
#[test]
fn check_same_missing_config_fails() {
let tmp = setup_git_repos(&["a"]);
let nonexistent = tmp.path().join("nope.toml");
let output = run(tmp.path(), &nonexistent, &["check-same"]);
assert!(!output.status.success());
}
#[test]
fn check_same_respects_select_glob() {
let tmp = setup_git_repos(&["pyalpha", "pybeta", "go-proj"]);
fs::write(tmp.path().join("pyalpha/Makefile"), "PY\n").unwrap();
fs::write(tmp.path().join("pybeta/Makefile"), "PY\n").unwrap();
fs::write(tmp.path().join("go-proj/Makefile"), "GO\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "py-make"
select = "py*"
path = "Makefile"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same"]);
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
stdout_str(&output),
stderr_str(&output)
);
}
#[test]
fn check_same_marker_requires_file() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.tag"), "").unwrap();
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "y\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
marker = ".tag"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same"]);
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
stdout_str(&output),
stderr_str(&output)
);
}
#[test]
fn check_same_disabled_rule_is_skipped() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "y\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
enabled = false
"#,
);
let output = run(tmp.path(), &cfg, &["check-same"]);
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
stdout_str(&output),
stderr_str(&output)
);
}
#[test]
fn check_same_checks_override_enabled_false() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "y\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
enabled = false
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--checks", "gi"]);
assert!(!output.status.success());
let stdout = stdout_str(&output);
assert!(stdout.contains("[gi]"), "stdout: {stdout}");
}
#[test]
fn check_same_verbose_reports_consistent_rules() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "same\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "same\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--verbose"]);
assert!(output.status.success());
let stdout = stdout_str(&output);
assert!(stdout.contains("[gi]"), "stdout: {stdout}");
assert!(stdout.contains("ok"), "stdout: {stdout}");
}
#[test]
fn check_same_diff_emits_unified_diff_for_two_groups() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(
tmp.path().join("a/.gitignore"),
"target\nnode_modules\n.env\n",
)
.unwrap();
fs::write(tmp.path().join("b/.gitignore"), "target\ndist\n.env\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--diff"]);
assert!(!output.status.success());
let stdout = stdout_str(&output);
assert!(
stdout.contains("--- "),
"stdout should contain unified diff header: {stdout}"
);
assert!(
stdout.contains("+++ "),
"stdout should contain unified diff header: {stdout}"
);
assert!(
stdout.contains("-node_modules"),
"should show removed line: {stdout}"
);
assert!(stdout.contains("+dist"), "should show added line: {stdout}");
}
#[test]
fn check_same_diff_three_groups_prompts_for_pair() {
let tmp = setup_git_repos(&["a", "b", "c"]);
fs::write(tmp.path().join("a/.gitignore"), "one\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "two\n").unwrap();
fs::write(tmp.path().join("c/.gitignore"), "three\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--diff"]);
assert!(!output.status.success());
let stdout = stdout_str(&output);
assert!(
stdout.contains("diff from group"),
"should prompt for a pair: {stdout}"
);
assert!(
!stdout.contains("--- "),
"stdout should not contain diff header: {stdout}"
);
}
#[test]
fn check_same_diff_off_by_default() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "target\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "dist\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same"]);
assert!(!output.status.success());
let stdout = stdout_str(&output);
assert!(!stdout.contains("+++ "), "no diff without --diff: {stdout}");
}
#[test]
fn check_same_diff_binary_files() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/blob.bin"), [0xffu8, 0xfe, 0x00, 0x01]).unwrap();
fs::write(tmp.path().join("b/blob.bin"), [0xffu8, 0xfe, 0x00, 0x02]).unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "bin"
select = "*"
path = "blob.bin"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--diff"]);
assert!(!output.status.success());
let stdout = stdout_str(&output);
assert!(
stdout.contains("binary files differ"),
"should note binary files: {stdout}"
);
}
#[test]
fn check_same_diff_suppressed_by_terse() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "target\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "dist\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same", "--terse", "--diff"]);
assert!(!output.status.success());
assert_eq!(stdout_str(&output), "gi");
}
#[test]
fn check_same_config_without_repos_fails() {
let tmp = setup_git_repos(&["a"]);
let cfg_path = tmp.path().join("no-repos.toml");
fs::write(
&cfg_path,
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
)
.unwrap();
let output = run(tmp.path(), &cfg_path, &["check-same"]);
assert!(!output.status.success());
let stderr = stderr_str(&output);
assert!(
stderr.contains("repos"),
"stderr should mention repos: {stderr}"
);
}
#[test]
fn config_example_does_not_require_existing_config() {
let tmp = setup_git_repos(&["a"]);
let missing = tmp.path().join("does-not-exist.toml");
let output = run_rsmultigit_with_env(
tmp.path(),
&["config-example"],
&[("RSMULTIGIT_CONFIG", &missing.to_string_lossy())],
);
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
let stdout = stdout_str(&output);
assert!(
stdout.contains("repos ="),
"should contain a `repos =` line: {stdout}"
);
assert!(
stdout.contains("[[check]]"),
"should contain at least one [[check]] block"
);
}
#[test]
fn config_example_output_parses_as_valid_config() {
let tmp = setup_git_repos(&["a"]);
fs::create_dir_all(tmp.path().join("a/.github/workflows")).unwrap();
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
let example = run_rsmultigit_with_env(
tmp.path(),
&["config-example"],
&[("RSMULTIGIT_CONFIG", "/nonexistent")],
);
assert!(example.status.success());
let example_toml = String::from_utf8_lossy(&example.stdout).into_owned();
let rewritten = regex_replace_repos_line(&example_toml, tmp.path());
let cfg_path = tmp.path().join("config.toml");
fs::write(&cfg_path, &rewritten).unwrap();
let output = run(tmp.path(), &cfg_path, &["list-checks"]);
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
assert!(!stdout_str(&output).is_empty());
}
fn regex_replace_repos_line(text: &str, dir: &Path) -> String {
let mut out = String::new();
let mut in_repos = false;
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("repos = [") {
out.push_str(&format!("repos = [\"{}/*\"]\n", dir.display()));
in_repos = !trimmed.contains(']');
continue;
}
if in_repos {
if trimmed.contains(']') {
in_repos = false;
}
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
#[test]
fn list_checks_prints_every_rule_name() {
let tmp = setup_git_repos(&["a"]);
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "alpha"
select = "*"
path = "a"
[[check]]
name = "beta"
select = "*"
path = "b"
enabled = false
[[check]]
name = "gamma"
select = "*"
path = "c"
"#,
);
let output = run(tmp.path(), &cfg, &["list-checks"]);
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
assert_eq!(stdout_str(&output), "alpha\nbeta\ngamma");
}
#[test]
fn list_checks_empty_when_no_rules() {
let tmp = setup_git_repos(&["a"]);
let cfg = write_config(tmp.path(), "");
let output = run(tmp.path(), &cfg, &["list-checks"]);
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
assert_eq!(stdout_str(&output), "");
}
#[test]
fn check_same_diff_three_groups_feeds_pair_and_exits() {
let tmp = setup_git_repos(&["a1", "a2", "a3", "b1", "b2", "c1"]);
for r in ["a1", "a2", "a3"] {
fs::write(tmp.path().join(r).join(".gitignore"), "one\n").unwrap();
}
for r in ["b1", "b2"] {
fs::write(tmp.path().join(r).join(".gitignore"), "two\n").unwrap();
}
fs::write(tmp.path().join("c1/.gitignore"), "three\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run_with_stdin(tmp.path(), &cfg, &["check-same", "--diff"], b"A\nB\nn\n");
assert!(
!output.status.success(),
"non-copy invocation: mismatch → exit 1"
);
let stdout = stdout_str(&output);
assert!(
stdout.contains("--- "),
"should emit a diff header: {stdout}"
);
assert!(
stdout.contains("-one"),
"should show group A content: {stdout}"
);
assert!(
stdout.contains("+two"),
"should show group B content: {stdout}"
);
}
#[test]
fn check_same_diff_three_groups_loops_for_more_pairs() {
let tmp = setup_git_repos(&["a1", "a2", "a3", "b1", "b2", "c1"]);
for r in ["a1", "a2", "a3"] {
fs::write(tmp.path().join(r).join(".gitignore"), "one\n").unwrap();
}
for r in ["b1", "b2"] {
fs::write(tmp.path().join(r).join(".gitignore"), "two\n").unwrap();
}
fs::write(tmp.path().join("c1/.gitignore"), "three\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run_with_stdin(
tmp.path(),
&cfg,
&["check-same", "--diff"],
b"A\nB\ny\nA\nC\nn\n",
);
let stdout = stdout_str(&output);
let plus_count = stdout.matches("+++ ").count();
assert_eq!(
plus_count, 2,
"expected 2 diff bodies, got {plus_count}: {stdout}"
);
}
#[test]
fn check_same_copy_overwrites_destination_group() {
let tmp = setup_git_repos(&["a", "b", "c"]);
fs::write(tmp.path().join("a/.gitignore"), "canonical\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "canonical\n").unwrap();
fs::write(tmp.path().join("c/.gitignore"), "stale\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run_with_stdin(tmp.path(), &cfg, &["check-same", "--copy"], b"A\nB\ny\n");
assert!(
output.status.success(),
"--copy always exits 0: stderr={}",
stderr_str(&output)
);
let c_content = fs::read_to_string(tmp.path().join("c/.gitignore")).unwrap();
assert_eq!(c_content, "canonical\n");
let a_content = fs::read_to_string(tmp.path().join("a/.gitignore")).unwrap();
assert_eq!(a_content, "canonical\n");
}
#[test]
fn check_same_copy_declined_leaves_files_alone() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "keep-a\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "keep-b\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run_with_stdin(tmp.path(), &cfg, &["check-same", "--copy"], b"A\nB\nn\n");
assert!(output.status.success());
let a = fs::read_to_string(tmp.path().join("a/.gitignore")).unwrap();
let b = fs::read_to_string(tmp.path().join("b/.gitignore")).unwrap();
assert_eq!(a, "keep-a\n");
assert_eq!(b, "keep-b\n");
}
#[test]
fn check_same_copy_always_exits_zero() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
fs::write(tmp.path().join("b/.gitignore"), "y\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run_with_stdin(tmp.path(), &cfg, &["check-same", "--copy"], b"q\n");
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
}
#[test]
fn check_same_copy_preserves_destination_mode() {
let tmp = setup_git_repos(&["a1", "a2", "b1"]);
fs::write(tmp.path().join("a1/.gitignore"), "from-a\n").unwrap();
fs::write(tmp.path().join("a2/.gitignore"), "from-a\n").unwrap();
fs::write(tmp.path().join("b1/.gitignore"), "old-b\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for r in ["a1", "a2"] {
let p = tmp.path().join(r).join(".gitignore");
let mut perm = fs::metadata(&p).unwrap().permissions();
perm.set_mode(0o644);
fs::set_permissions(&p, perm).unwrap();
}
let p = tmp.path().join("b1/.gitignore");
let mut perm = fs::metadata(&p).unwrap().permissions();
perm.set_mode(0o600);
fs::set_permissions(&p, perm).unwrap();
}
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run_with_stdin(tmp.path(), &cfg, &["check-same", "--copy"], b"A\nB\ny\n");
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
let content = fs::read_to_string(tmp.path().join("b1/.gitignore")).unwrap();
assert_eq!(content, "from-a\n");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(tmp.path().join("b1/.gitignore"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "destination mode should be preserved");
}
}
#[test]
fn check_same_copy_three_groups_picks_from_and_to() {
let tmp = setup_git_repos(&["a1", "a2", "a3", "b1", "b2", "c1"]);
for r in ["a1", "a2", "a3"] {
fs::write(tmp.path().join(r).join(".gitignore"), "canonical\n").unwrap();
}
for r in ["b1", "b2"] {
fs::write(tmp.path().join(r).join(".gitignore"), "stale-1\n").unwrap();
}
fs::write(tmp.path().join("c1/.gitignore"), "stale-2\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run_with_stdin(tmp.path(), &cfg, &["check-same", "--copy"], b"A\nC\ny\n");
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
assert_eq!(
fs::read_to_string(tmp.path().join("c1/.gitignore")).unwrap(),
"canonical\n"
);
assert_eq!(
fs::read_to_string(tmp.path().join("b1/.gitignore")).unwrap(),
"stale-1\n"
);
assert_eq!(
fs::read_to_string(tmp.path().join("b2/.gitignore")).unwrap(),
"stale-1\n"
);
}
#[test]
fn check_same_copy_rejects_same_group_as_from_and_to() {
let tmp = setup_git_repos(&["a1", "a2", "b1"]);
fs::write(tmp.path().join("a1/.gitignore"), "from-a\n").unwrap();
fs::write(tmp.path().join("a2/.gitignore"), "from-a\n").unwrap();
fs::write(tmp.path().join("b1/.gitignore"), "bb\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run_with_stdin(tmp.path(), &cfg, &["check-same", "--copy"], b"A\nA\nB\ny\n");
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
let stdout = stdout_str(&output);
assert!(
stdout.contains("invalid choice"),
"should reject same group: {stdout}"
);
assert_eq!(
fs::read_to_string(tmp.path().join("b1/.gitignore")).unwrap(),
"from-a\n"
);
}
#[test]
fn check_same_must_have_false_ignores_missing_files() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
"#,
);
let output = run(tmp.path(), &cfg, &["check-same"]);
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
stdout_str(&output),
stderr_str(&output)
);
}
#[test]
fn check_same_must_have_true_flags_missing_files() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
must_have = true
"#,
);
let output = run(tmp.path(), &cfg, &["check-same"]);
assert!(
!output.status.success(),
"must_have violation should exit 1"
);
let stdout = stdout_str(&output);
assert!(
stdout.contains("1 missing"),
"summary should mention missing count: {stdout}"
);
assert!(
stdout.contains("missing in:"),
"should emit missing-in block: {stdout}"
);
assert!(
stdout.contains(tmp.path().join("b").to_string_lossy().as_ref()),
"should list the violating repo: {stdout}"
);
}
#[test]
fn check_same_fix_missing_creates_files() {
let tmp = setup_git_repos(&["a", "b", "c"]);
fs::write(tmp.path().join("a/.gitignore"), "canonical\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
must_have = true
"#,
);
let output = run_with_stdin(
tmp.path(),
&cfg,
&["check-same", "--fix-missing"],
b"A\ny\n",
);
assert!(
output.status.success(),
"--fix-missing always exits 0: stderr={}",
stderr_str(&output)
);
assert_eq!(
fs::read_to_string(tmp.path().join("b/.gitignore")).unwrap(),
"canonical\n"
);
assert_eq!(
fs::read_to_string(tmp.path().join("c/.gitignore")).unwrap(),
"canonical\n"
);
assert_eq!(
fs::read_to_string(tmp.path().join("a/.gitignore")).unwrap(),
"canonical\n"
);
}
#[test]
fn check_same_fix_missing_creates_parent_directories() {
let tmp = setup_git_repos(&["a", "b"]);
let nested = tmp.path().join("a/.github/workflows/build.yml");
fs::create_dir_all(nested.parent().unwrap()).unwrap();
fs::write(&nested, "on: push\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "wf"
select = "*"
path = ".github/workflows/build.yml"
must_have = true
"#,
);
let output = run_with_stdin(
tmp.path(),
&cfg,
&["check-same", "--fix-missing"],
b"A\ny\n",
);
assert!(output.status.success(), "stderr: {}", stderr_str(&output));
let dst = tmp.path().join("b/.github/workflows/build.yml");
assert!(
dst.exists(),
"nested file should have been created: {}",
dst.display()
);
assert_eq!(fs::read_to_string(&dst).unwrap(), "on: push\n");
}
#[test]
fn check_same_fix_missing_declined_leaves_alone() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "canonical\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
must_have = true
"#,
);
let output = run_with_stdin(
tmp.path(),
&cfg,
&["check-same", "--fix-missing"],
b"A\nn\n",
);
assert!(output.status.success());
assert!(
!tmp.path().join("b/.gitignore").exists(),
"file should not have been created"
);
}
#[test]
fn check_same_fix_missing_with_no_groups_skips() {
let tmp = setup_git_repos(&["a", "b"]);
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
must_have = true
"#,
);
let output = run_with_stdin(tmp.path(), &cfg, &["check-same", "--fix-missing"], b"");
assert!(output.status.success(), "should still exit 0");
let stdout = stdout_str(&output);
assert!(
stdout.contains("nothing to seed from"),
"should note no seed available: {stdout}"
);
assert!(!tmp.path().join("a/.gitignore").exists());
assert!(!tmp.path().join("b/.gitignore").exists());
}
#[test]
fn check_same_fix_missing_always_exits_zero() {
let tmp = setup_git_repos(&["a", "b"]);
fs::write(tmp.path().join("a/.gitignore"), "x\n").unwrap();
let cfg = write_config(
tmp.path(),
r#"
[[check]]
name = "gi"
select = "*"
path = ".gitignore"
must_have = true
"#,
);
let output = run_with_stdin(tmp.path(), &cfg, &["check-same", "--fix-missing"], b"q\n");
assert!(output.status.success());
}