use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn anodizer() -> Command {
Command::new(env!("CARGO_BIN_EXE_anodizer"))
}
fn run_git(dir: &Path, args: &[&str]) {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir).args(args);
cmd
},
"git",
);
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn git_out(dir: &Path, args: &[&str]) -> String {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir).args(args);
cmd
},
"git",
);
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
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_add_commit(dir: &Path, message: &str) {
run_git(dir, &["add", "-A"]);
run_git(dir, &["commit", "-q", "-m", message]);
}
fn head_sha(dir: &Path) -> String {
git_out(dir, &["rev-parse", "HEAD"])
}
fn remote_branch_sha(bare: &Path, branch: &str) -> Option<String> {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["--git-dir"])
.arg(bare)
.args(["rev-parse", &format!("refs/heads/{branch}")]);
cmd
},
"git",
);
if out.status.success() {
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
None
}
}
fn remote_tag_target(bare: &Path, tag: &str) -> Option<String> {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["--git-dir"])
.arg(bare)
.args(["rev-parse", &format!("refs/tags/{tag}^{{}}")]);
cmd
},
"git",
);
if out.status.success() {
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
None
}
}
fn make_bare() -> TempDir {
let bare = TempDir::new().unwrap();
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["init", "--bare", "-q", "-b", "master"])
.arg(bare.path());
cmd
},
"git",
);
assert!(out.status.success(), "git init --bare failed");
bare
}
fn lockstep_with_origin() -> (TempDir, TempDir) {
let work = TempDir::new().unwrap();
let bare = make_bare();
fs::write(
work.path().join("Cargo.toml"),
r#"[workspace]
members = ["crates/a"]
resolver = "2"
[workspace.package]
version = "0.1.0"
"#,
)
.unwrap();
fs::create_dir_all(work.path().join("crates/a/src")).unwrap();
fs::write(
work.path().join("crates/a/Cargo.toml"),
r#"[package]
name = "a"
version.workspace = true
edition = "2024"
"#,
)
.unwrap();
fs::write(work.path().join("crates/a/src/lib.rs"), "").unwrap();
git_init(work.path());
run_git(
work.path(),
&["remote", "add", "origin", bare.path().to_str().unwrap()],
);
git_add_commit(work.path(), "initial");
run_git(work.path(), &["push", "origin", "master"]);
run_git(work.path(), &["tag", "v0.1.0"]);
run_git(work.path(), &["push", "origin", "v0.1.0"]);
fs::write(work.path().join("crates/a/src/lib.rs"), "// touched\n").unwrap();
git_add_commit(work.path(), "fix: a deref issue");
(work, bare)
}
fn per_crate_with_origin() -> (TempDir, TempDir) {
let work = TempDir::new().unwrap();
let bare = make_bare();
fs::write(
work.path().join("Cargo.toml"),
r#"[workspace]
members = ["crates/core", "crates/cli"]
resolver = "2"
"#,
)
.unwrap();
for (name, path) in [("core", "crates/core"), ("cli", "crates/cli")] {
fs::create_dir_all(work.path().join(path).join("src")).unwrap();
fs::write(
work.path().join(path).join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(work.path().join(path).join("src/lib.rs"), "").unwrap();
}
fs::write(
work.path().join(".anodizer.yaml"),
r#"project_name: myproj
crates:
- name: core
path: crates/core
tag_template: "core-v{{ .Version }}"
version_sync:
enabled: true
- name: cli
path: crates/cli
tag_template: "cli-v{{ .Version }}"
version_sync:
enabled: true
"#,
)
.unwrap();
git_init(work.path());
run_git(
work.path(),
&["remote", "add", "origin", bare.path().to_str().unwrap()],
);
git_add_commit(work.path(), "initial");
run_git(work.path(), &["push", "origin", "master"]);
run_git(work.path(), &["tag", "core-v0.1.0"]);
run_git(work.path(), &["tag", "cli-v0.1.0"]);
run_git(
work.path(),
&["push", "origin", "core-v0.1.0", "cli-v0.1.0"],
);
fs::write(work.path().join("crates/core/src/lib.rs"), "// touched\n").unwrap();
git_add_commit(work.path(), "fix: core bug");
(work, bare)
}
#[test]
fn lockstep_default_orphans_bump_commit_on_remote() {
let (work, bare) = lockstep_with_origin();
let remote_master_before = remote_branch_sha(bare.path(), "master").unwrap();
let out = anodizer()
.current_dir(work.path())
.args(["tag"])
.output()
.unwrap();
assert!(
out.status.success(),
"tag failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let local_head = head_sha(work.path());
let remote_master_after = remote_branch_sha(bare.path(), "master").unwrap();
let tag_target = remote_tag_target(bare.path(), "v0.1.1").expect("tag pushed");
assert_eq!(tag_target, local_head, "tag should target the bump commit");
assert_eq!(
remote_master_after, remote_master_before,
"default run must NOT advance remote master"
);
assert_ne!(
remote_master_after, local_head,
"default run leaves remote master behind the tag target (orphan)"
);
}
#[test]
fn lockstep_push_lands_bump_commit_atomically() {
let (work, bare) = lockstep_with_origin();
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--push"])
.output()
.unwrap();
assert!(
out.status.success(),
"tag --push failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let local_head = head_sha(work.path());
let remote_master = remote_branch_sha(bare.path(), "master").expect("master on remote");
let tag_target = remote_tag_target(bare.path(), "v0.1.1").expect("tag pushed");
assert_eq!(
remote_master, local_head,
"remote master must reach the bump commit (no orphan)"
);
assert_eq!(
tag_target, local_head,
"remote tag must point at the bump commit"
);
assert_eq!(
remote_master, tag_target,
"remote branch and tag must agree (atomic push)"
);
}
#[test]
fn push_noop_when_no_version_change_creates_no_bump_commit() {
let work = TempDir::new().unwrap();
let bare = make_bare();
fs::write(
work.path().join("Cargo.toml"),
r#"[workspace]
members = ["crates/a"]
resolver = "2"
[workspace.package]
version = "0.1.0"
"#,
)
.unwrap();
fs::create_dir_all(work.path().join("crates/a/src")).unwrap();
fs::write(
work.path().join("crates/a/Cargo.toml"),
"[package]\nname = \"a\"\nversion.workspace = true\nedition = \"2024\"\n",
)
.unwrap();
fs::write(work.path().join("crates/a/src/lib.rs"), "").unwrap();
git_init(work.path());
run_git(
work.path(),
&["remote", "add", "origin", bare.path().to_str().unwrap()],
);
git_add_commit(work.path(), "initial");
run_git(work.path(), &["push", "origin", "master"]);
run_git(work.path(), &["tag", "v0.1.0"]);
run_git(work.path(), &["push", "origin", "v0.1.0"]);
fs::write(work.path().join("crates/a/src/lib.rs"), "// chore\n").unwrap();
git_add_commit(work.path(), "chore: tidy #none");
let head_before = head_sha(work.path());
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--push"])
.output()
.unwrap();
assert!(
out.status.success(),
"no-op tag --push must exit 0: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let head_after = head_sha(work.path());
assert_eq!(
head_before, head_after,
"no version change must NOT create a bump commit"
);
let log = git_out(work.path(), &["log", "--format=%s", "-5"]);
assert!(
!log.contains("chore(release): bump"),
"no `chore(release): bump` commit should be added: {log}"
);
}
#[test]
fn push_non_fast_forward_leaves_no_orphan_branch_or_tag() {
let (work, bare) = lockstep_with_origin();
let other = TempDir::new().unwrap();
run_git(
other.path(),
&["clone", "-q", bare.path().to_str().unwrap(), "."],
);
run_git(other.path(), &["config", "user.email", "o@o.com"]);
run_git(other.path(), &["config", "user.name", "Other"]);
fs::write(other.path().join("UNRELATED"), "x\n").unwrap();
git_add_commit(other.path(), "chore: advance remote");
run_git(other.path(), &["push", "origin", "master"]);
let remote_master_before = remote_branch_sha(bare.path(), "master").unwrap();
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--push"])
.output()
.unwrap();
assert!(
!out.status.success(),
"non-fast-forward push must fail: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("non-fast-forward") && stderr.contains("Pull/rebase"),
"expected an actionable non-fast-forward hint: {stderr}"
);
let remote_master_after = remote_branch_sha(bare.path(), "master").unwrap();
assert_eq!(
remote_master_after, remote_master_before,
"rejected push must NOT move remote master"
);
assert_eq!(
remote_tag_target(bare.path(), "v0.1.1"),
None,
"rejected atomic push must NOT leave a dangling remote tag"
);
}
#[test]
fn push_remote_targets_named_remote() {
let (work, _origin) = lockstep_with_origin();
let upstream = make_bare();
run_git(
work.path(),
&[
"remote",
"add",
"upstream",
upstream.path().to_str().unwrap(),
],
);
run_git(work.path(), &["push", "upstream", "v0.1.0"]);
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--push", "--push-remote", "upstream"])
.output()
.unwrap();
assert!(
out.status.success(),
"tag --push --push-remote upstream failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let local_head = head_sha(work.path());
assert_eq!(
remote_branch_sha(upstream.path(), "master").as_deref(),
Some(local_head.as_str()),
"upstream master must reach the bump commit"
);
assert_eq!(
remote_tag_target(upstream.path(), "v0.1.1").as_deref(),
Some(local_head.as_str()),
"upstream tag must point at the bump commit"
);
}
#[test]
fn per_crate_no_push_pushes_tags_but_not_branch() {
let (work, bare) = per_crate_with_origin();
let remote_master_before = remote_branch_sha(bare.path(), "master").unwrap();
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--no-push"])
.output()
.unwrap();
assert!(
out.status.success(),
"per-crate tag --no-push failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let local_head = head_sha(work.path());
let remote_master_after = remote_branch_sha(bare.path(), "master").unwrap();
assert_eq!(
remote_master_after, remote_master_before,
"--no-push must NOT advance remote master"
);
assert_ne!(
remote_master_after, local_head,
"--no-push leaves remote master behind the bump commit"
);
assert_eq!(
remote_tag_target(bare.path(), "core-v0.1.1").as_deref(),
Some(local_head.as_str()),
"--no-push must still push the tag"
);
}
#[test]
fn per_crate_default_pushes_branch_and_tags_atomically() {
let (work, bare) = per_crate_with_origin();
let out = anodizer()
.current_dir(work.path())
.args(["tag"])
.output()
.unwrap();
assert!(
out.status.success(),
"per-crate tag failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let local_head = head_sha(work.path());
assert_eq!(
remote_branch_sha(bare.path(), "master").as_deref(),
Some(local_head.as_str()),
"per-crate default must advance remote master to the bump commit"
);
assert_eq!(
remote_tag_target(bare.path(), "core-v0.1.1").as_deref(),
Some(local_head.as_str()),
"per-crate default must push the tag"
);
}
#[test]
fn crate_targeted_push_lands_bump_commit_atomically() {
let (work, bare) = per_crate_with_origin();
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--crate", "core", "--push"])
.output()
.unwrap();
assert!(
out.status.success(),
"tag --crate core --push failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let local_head = head_sha(work.path());
let remote_master = remote_branch_sha(bare.path(), "master").expect("master on remote");
let tag_target = remote_tag_target(bare.path(), "core-v0.1.1").expect("tag pushed");
assert_eq!(
remote_master, local_head,
"remote master must reach the bump commit (no orphan)"
);
assert_eq!(
tag_target, local_head,
"remote tag must point at the bump commit"
);
assert_eq!(
remote_master, tag_target,
"remote branch and tag must agree (atomic push)"
);
assert_eq!(
remote_tag_target(bare.path(), "cli-v0.1.1"),
None,
"cli must not be tagged when only core was targeted"
);
}
#[test]
fn failed_version_sync_bump_commit_aborts_before_tagging() {
let (work, bare) = per_crate_with_origin();
let hooks_dir = work.path().join(".git/hooks");
fs::create_dir_all(&hooks_dir).unwrap();
let hook = hooks_dir.join("pre-commit");
fs::write(&hook, "#!/bin/sh\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook, perms).unwrap();
}
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--crate", "core", "--push"])
.output()
.unwrap();
assert!(
!out.status.success(),
"tag must fail when the version_sync bump commit is rejected: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let local_tags = git_out(work.path(), &["tag", "--list", "core-v0.1.1"]);
assert!(
local_tags.is_empty(),
"a failed bump must not leave a local tag: {local_tags:?}"
);
assert_eq!(
remote_tag_target(bare.path(), "core-v0.1.1"),
None,
"a failed bump must not produce a pushed tag"
);
}
#[test]
fn push_dry_run_creates_tag_locally_but_pushes_nothing() {
let (work, bare) = lockstep_with_origin();
let remote_master_before = remote_branch_sha(bare.path(), "master").unwrap();
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--push-dry-run"])
.output()
.unwrap();
assert!(
out.status.success(),
"tag --push-dry-run must exit 0: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("(dry-run) would push"),
"expected a '(dry-run) would push' line: {combined}"
);
let remote_master_after = remote_branch_sha(bare.path(), "master").unwrap();
assert_eq!(
remote_master_after, remote_master_before,
"--push-dry-run must NOT advance remote master"
);
assert_eq!(
remote_tag_target(bare.path(), "v0.1.1"),
None,
"--push-dry-run must NOT push the tag"
);
}
#[test]
fn tag_rollback_rejects_push_flags() {
let (work, _bare) = lockstep_with_origin();
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--push", "rollback", "--dry-run"])
.output()
.unwrap();
assert!(
!out.status.success(),
"tag --push rollback must be rejected: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--push family") && stderr.contains("tag rollback"),
"expected a clear rollback rejection message: {stderr}"
);
}
fn latest_bump_subject(dir: &Path) -> String {
let log = git_out(dir, &["log", "--format=%s", "-10"]);
log.lines()
.find(|s| s.contains("bump"))
.unwrap_or("")
.to_string()
}
#[test]
fn lockstep_bump_subject_omits_skip_ci_by_default() {
let (work, _bare) = lockstep_with_origin();
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--no-push"])
.output()
.unwrap();
assert!(
out.status.success(),
"tag failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let subject = latest_bump_subject(work.path());
assert!(
subject.contains("bump workspace"),
"expected a workspace bump commit: {subject}"
);
assert!(
!subject.contains("[skip ci]"),
"default bump subject must NOT contain [skip ci]: {subject}"
);
}
#[test]
fn lockstep_bump_subject_has_skip_ci_when_enabled() {
let (work, _bare) = lockstep_with_origin();
fs::write(
work.path().join(".anodizer.yaml"),
"project_name: myproj\ntag:\n skip_ci_on_bump: true\n",
)
.unwrap();
git_add_commit(work.path(), "chore: enable skip_ci_on_bump");
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--no-push"])
.output()
.unwrap();
assert!(
out.status.success(),
"tag failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let subject = latest_bump_subject(work.path());
assert!(
subject.contains("bump workspace") && subject.contains("[skip ci]"),
"enabled bump subject must contain [skip ci]: {subject}"
);
}
#[test]
fn per_crate_bump_subject_omits_skip_ci_by_default() {
let (work, _bare) = per_crate_with_origin();
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--no-push"])
.output()
.unwrap();
assert!(
out.status.success(),
"tag failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let subject = latest_bump_subject(work.path());
assert!(
subject.contains("chore(release): bump"),
"expected a per-crate bump commit: {subject}"
);
assert!(
!subject.contains("[skip ci]"),
"default per-crate bump subject must NOT contain [skip ci]: {subject}"
);
}
#[test]
fn per_crate_bump_subject_has_skip_ci_when_enabled() {
let (work, _bare) = per_crate_with_origin();
fs::write(
work.path().join(".anodizer.yaml"),
r#"project_name: myproj
tag:
skip_ci_on_bump: true
crates:
- name: core
path: crates/core
tag_template: "core-v{{ .Version }}"
version_sync:
enabled: true
- name: cli
path: crates/cli
tag_template: "cli-v{{ .Version }}"
version_sync:
enabled: true
"#,
)
.unwrap();
git_add_commit(work.path(), "chore: enable skip_ci_on_bump");
let out = anodizer()
.current_dir(work.path())
.args(["tag", "--no-push"])
.output()
.unwrap();
assert!(
out.status.success(),
"tag failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let subject = latest_bump_subject(work.path());
assert!(
subject.contains("chore(release): bump") && subject.contains("[skip ci]"),
"enabled per-crate bump subject must contain [skip ci]: {subject}"
);
}