use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn anodizer() -> (Command, TempDir) {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_anodizer"));
let scratch = sandbox_env(&mut cmd);
(cmd, scratch)
}
#[must_use = "hold the returned TempDir until after the command runs"]
fn sandbox_env(cmd: &mut Command) -> TempDir {
let scratch = TempDir::new().expect("scratch tempdir for HOME redirect");
sandbox_env_at(cmd, scratch.path());
scratch
}
fn sandbox_env_at(cmd: &mut Command, path: &Path) {
cmd.env("HOME", path)
.env("XDG_CONFIG_HOME", path)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null");
}
fn run_git(dir: &Path, args: &[&str]) {
let scratch = TempDir::new().expect("scratch tempdir for HOME redirect");
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir).args(args);
sandbox_env_at(&mut cmd, scratch.path());
cmd.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com");
cmd
},
"git",
);
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn git_init(dir: &Path) {
run_git(dir, &["init", "-q", "-b", "master"]);
run_git(dir, &["config", "user.email", "test@test.com"]);
run_git(dir, &["config", "user.name", "Test"]);
run_git(dir, &["config", "commit.gpgsign", "false"]);
}
fn git_init_no_identity(dir: &Path) {
run_git(dir, &["init", "-q", "-b", "master"]);
run_git(dir, &["config", "commit.gpgsign", "false"]);
}
fn add_non_github_origin(dir: &Path) {
run_git(
dir,
&["remote", "add", "origin", "https://gitlab.example/o/r.git"],
);
}
fn git_tag_exists(dir: &Path, tag: &str) -> bool {
let scratch = TempDir::new().expect("scratch tempdir for HOME redirect");
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir).args(["tag", "-l", tag]);
sandbox_env_at(&mut cmd, scratch.path());
cmd
},
"git",
);
!String::from_utf8_lossy(&out.stdout).trim().is_empty()
}
fn git_head_subject(dir: &Path) -> String {
let scratch = TempDir::new().expect("scratch tempdir for HOME redirect");
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir)
.args(["log", "-1", "--format=%s", "HEAD"]);
sandbox_env_at(&mut cmd, scratch.path());
cmd
},
"git",
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn git_head_sha(dir: &Path) -> String {
let scratch = TempDir::new().expect("scratch tempdir for HOME redirect");
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir).args(["rev-parse", "HEAD"]);
sandbox_env_at(&mut cmd, scratch.path());
cmd
},
"git",
);
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
#[test]
fn tag_rollback_local_deletes_tag_and_creates_revert_commit() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
git_init(dir);
fs::write(dir.join("README.md"), "init\n").unwrap();
run_git(dir, &["add", "-A"]);
run_git(dir, &["commit", "-q", "-m", "initial"]);
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"demo\"\nversion = \"1.0.0\"\n",
)
.unwrap();
run_git(dir, &["add", "-A"]);
run_git(dir, &["commit", "-q", "-m", "chore(release): v1.0.0"]);
run_git(dir, &["tag", "v1.0.0"]);
add_non_github_origin(dir);
assert!(
git_tag_exists(dir, "v1.0.0"),
"fixture setup: tag v1.0.0 should exist before rollback"
);
let (mut cmd, _scratch) = anodizer();
let out = cmd
.current_dir(dir)
.args(["tag", "rollback", "--no-push"])
.output()
.expect("anodizer tag rollback should spawn");
assert!(
out.status.success(),
"anodizer tag rollback exited with status {:?}\nstdout:\n{}\nstderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert!(
!git_tag_exists(dir, "v1.0.0"),
"expected v1.0.0 to be deleted after rollback"
);
let subj = git_head_subject(dir);
assert!(
subj.starts_with("chore(release): rollback v1.0.0"),
"unexpected HEAD subject after rollback: {subj}"
);
assert!(
subj.contains("[skip ci]"),
"rollback commit subject should contain [skip ci]: {subj}"
);
}
#[test]
fn tag_rollback_dry_run_makes_no_mutations() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
git_init(dir);
fs::write(dir.join("README.md"), "init\n").unwrap();
run_git(dir, &["add", "-A"]);
run_git(dir, &["commit", "-q", "-m", "initial"]);
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"demo\"\nversion = \"1.0.0\"\n",
)
.unwrap();
run_git(dir, &["add", "-A"]);
run_git(dir, &["commit", "-q", "-m", "chore(release): v1.0.0"]);
run_git(dir, &["tag", "v1.0.0"]);
add_non_github_origin(dir);
let head_before = git_head_sha(dir);
let (mut cmd, _scratch) = anodizer();
let out = cmd
.current_dir(dir)
.args(["tag", "rollback", "--dry-run", "--no-push"])
.output()
.expect("anodizer tag rollback --dry-run should spawn");
assert!(out.status.success(), "dry-run should succeed");
assert!(
git_tag_exists(dir, "v1.0.0"),
"dry-run must not delete tags"
);
let head_after = git_head_sha(dir);
assert_eq!(head_before, head_after, "dry-run must not move HEAD");
}
#[test]
fn tag_rollback_works_on_identity_less_host() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
git_init_no_identity(dir);
fs::write(dir.join("README.md"), "init\n").unwrap();
run_git(dir, &["add", "-A"]);
run_git(dir, &["commit", "-q", "-m", "initial"]);
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"demo\"\nversion = \"1.0.0\"\n",
)
.unwrap();
run_git(dir, &["add", "-A"]);
run_git(dir, &["commit", "-q", "-m", "chore(release): v1.0.0"]);
run_git(dir, &["tag", "v1.0.0"]);
add_non_github_origin(dir);
let mut cmd = Command::new(env!("CARGO_BIN_EXE_anodizer"));
cmd.current_dir(dir)
.args(["tag", "rollback", "--no-push"])
.env_remove("GIT_AUTHOR_NAME")
.env_remove("GIT_AUTHOR_EMAIL")
.env_remove("GIT_COMMITTER_NAME")
.env_remove("GIT_COMMITTER_EMAIL");
let _scratch = sandbox_env(&mut cmd);
let out = cmd
.output()
.expect("anodizer tag rollback should spawn on identity-less host");
assert!(
out.status.success(),
"rollback must succeed without configured/inherited committer identity; \
stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!git_tag_exists(dir, "v1.0.0"),
"expected v1.0.0 to be deleted on identity-less rollback"
);
let subj = git_head_subject(dir);
assert!(
subj.starts_with("chore(release): rollback v1.0.0"),
"revert must land even when user.email/name are unset: {subj}"
);
let cfg_scratch = TempDir::new().expect("scratch tempdir for HOME redirect");
let cfg = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cfg_cmd = Command::new("git");
cfg_cmd
.current_dir(dir)
.args(["config", "--local", "--get", "user.email"]);
sandbox_env_at(&mut cfg_cmd, cfg_scratch.path());
cfg_cmd
},
"git",
);
assert!(
!cfg.status.success() || cfg.stdout.is_empty(),
"rollback must not write user.email into repo's local config; got: {}",
String::from_utf8_lossy(&cfg.stdout)
);
}