use super::release_git::{push_release, stage_release_metadata};
use super::{
PushFailure, classify_push_failure, get_benchmarks_cargo_lock_path, update_cargo_lock,
update_cargo_locks,
};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn fixture_git(repo: &std::path::Path, args: &[&str]) -> String {
let output = Command::new("git")
.current_dir(repo)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
fn init_release_repo(repo: &std::path::Path) {
fixture_git(repo, &["init", "-q", "--initial-branch=main"]);
fixture_git(repo, &["config", "user.name", "Test"]);
fixture_git(repo, &["config", "user.email", "test@example.com"]);
fs::write(repo.join("Cargo.toml"), "version = \"1.4.0\"\n").unwrap();
fixture_git(repo, &["add", "Cargo.toml"]);
fixture_git(repo, &["commit", "-qm", "base"]);
}
#[test]
fn release_staging_rejects_already_staged_and_untracked_unrelated_files() {
let repo = temp_dir("staging-allowlist");
init_release_repo(&repo);
fs::write(repo.join("Cargo.toml"), "version = \"1.4.1\"\n").unwrap();
fs::write(repo.join("unrelated.txt"), "must never be released").unwrap();
fixture_git(&repo, &["add", "unrelated.txt"]);
assert!(stage_release_metadata(&repo, ".").is_err());
fixture_git(&repo, &["reset", "-q", "HEAD", "--", "unrelated.txt"]);
assert!(stage_release_metadata(&repo, ".").is_err());
fs::remove_file(repo.join("unrelated.txt")).unwrap();
stage_release_metadata(&repo, ".").unwrap();
assert_eq!(
fixture_git(&repo, &["diff", "--cached", "--name-only"]),
"Cargo.toml"
);
fs::remove_dir_all(repo).unwrap();
}
#[test]
fn monorepo_release_stages_only_its_metadata_and_consumed_fragments() {
let repo = temp_dir("staging-monorepo");
init_release_repo(&repo);
let package = repo.join("rust");
fs::create_dir_all(package.join("changelog.d")).unwrap();
fs::create_dir_all(package.join("benchmarks")).unwrap();
fs::write(package.join("Cargo.toml"), "version = \"1.4.0\"\n").unwrap();
fs::write(package.join("changelog.d/release.md"), "patch").unwrap();
fixture_git(&repo, &["add", "rust"]);
fixture_git(&repo, &["commit", "-qm", "package"]);
fs::write(package.join("Cargo.toml"), "version = \"1.4.1\"\n").unwrap();
fs::write(package.join("benchmarks/Cargo.lock"), "lockfile").unwrap();
fs::remove_file(package.join("changelog.d/release.md")).unwrap();
stage_release_metadata(&repo, "rust").unwrap();
stage_release_metadata(&repo, "./rust/").unwrap();
stage_release_metadata(&repo, package.to_str().unwrap()).unwrap();
stage_release_metadata(&package, ".").unwrap();
assert_eq!(
fixture_git(&repo, &["diff", "--cached", "--name-only"]),
"rust/Cargo.toml\nrust/benchmarks/Cargo.lock\nrust/changelog.d/release.md"
);
fs::write(repo.join("Cargo.toml"), "an unrelated package change").unwrap();
assert!(stage_release_metadata(&repo, "rust").is_err());
assert!(stage_release_metadata(&package, ".").is_err());
fs::remove_dir_all(repo).unwrap();
}
#[test]
fn release_tag_points_to_the_commit_pushed_after_a_lost_race() {
let root = temp_dir("tag-push-race");
let repo = root.join("local");
let remote = root.join("remote.git");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&remote).unwrap();
fixture_git(&remote, &["init", "-q", "--bare", "--initial-branch=main"]);
init_release_repo(&repo);
fixture_git(
&repo,
&["remote", "add", "origin", remote.to_str().unwrap()],
);
fixture_git(&repo, &["push", "-u", "origin", "main"]);
let other = root.join("other");
fixture_git(
&root,
&[
"clone",
"-q",
remote.to_str().unwrap(),
other.to_str().unwrap(),
],
);
fixture_git(&other, &["config", "user.name", "Test"]);
fixture_git(&other, &["config", "user.email", "test@example.com"]);
fs::write(other.join("parallel.txt"), "another main commit").unwrap();
fixture_git(&other, &["add", "parallel.txt"]);
fixture_git(&other, &["commit", "-qm", "parallel main update"]);
fixture_git(&other, &["push", "origin", "main"]);
fs::write(repo.join("Cargo.toml"), "version = \"1.4.1\"\n").unwrap();
fixture_git(&repo, &["add", "Cargo.toml"]);
fixture_git(&repo, &["commit", "-qm", "release"]);
push_release(&repo, "main", "rust-v1.4.1", "Release rust-v1.4.1").unwrap();
let head = fixture_git(&repo, &["rev-parse", "HEAD"]);
assert_eq!(fixture_git(&remote, &["rev-parse", "main"]), head);
assert_eq!(
fixture_git(&remote, &["rev-parse", "rust-v1.4.1^{commit}"]),
head
);
fs::remove_dir_all(root).unwrap();
}
fn temp_dir(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("version-and-commit-{name}-{nanos}"));
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn cargo_lock_update_scopes_to_named_package_entry() {
let repo = temp_dir("lock-update");
let cargo_lock = repo.join("Cargo.lock");
fs::write(
&cargo_lock,
r#"[[package]]
name = "example-sum-package-name-helper"
version = "9.9.9"
[[package]]
name = "example-sum-package-name"
version = "0.13.0"
dependencies = [
"clap",
]
[[package]]
name = "regex"
version = "1.12.3"
"#,
)
.unwrap();
assert!(update_cargo_lock(&cargo_lock, "example-sum-package-name", "0.14.0").unwrap());
let updated = fs::read_to_string(&cargo_lock).unwrap();
assert!(updated.contains("name = \"example-sum-package-name\"\nversion = \"0.14.0\""));
assert!(updated.contains("name = \"example-sum-package-name-helper\"\nversion = \"9.9.9\""));
assert!(updated.contains("name = \"regex\"\nversion = \"1.12.3\""));
}
#[test]
fn cargo_lock_update_is_idempotent_when_version_already_matches() {
let repo = temp_dir("lock-idempotent");
let cargo_lock = repo.join("Cargo.lock");
let content = r#"[[package]]
name = "example-sum-package-name"
version = "0.14.0"
"#;
fs::write(&cargo_lock, content).unwrap();
assert!(!update_cargo_lock(&cargo_lock, "example-sum-package-name", "0.14.0").unwrap());
assert_eq!(fs::read_to_string(&cargo_lock).unwrap(), content);
}
#[test]
fn cargo_lock_update_returns_false_when_lock_file_is_absent() {
let repo = temp_dir("lock-absent");
let cargo_lock = repo.join("Cargo.lock");
assert!(!update_cargo_lock(&cargo_lock, "example-sum-package-name", "0.14.0").unwrap());
assert!(!cargo_lock.exists());
}
#[test]
fn cargo_lock_update_returns_false_when_package_entry_is_absent() {
let repo = temp_dir("lock-entry-absent");
let cargo_lock = repo.join("Cargo.lock");
let content = r#"[[package]]
name = "regex"
version = "1.12.3"
"#;
fs::write(&cargo_lock, content).unwrap();
assert!(!update_cargo_lock(&cargo_lock, "example-sum-package-name", "0.14.0").unwrap());
assert_eq!(fs::read_to_string(&cargo_lock).unwrap(), content);
}
#[test]
fn cargo_locks_update_includes_benchmarks_lock_file() {
let rust_root = temp_dir("locks-benchmarks");
let rust_root_str = rust_root.to_string_lossy().to_string();
let benchmarks_lock = get_benchmarks_cargo_lock_path(&rust_root_str);
assert_eq!(
benchmarks_lock,
rust_root.join("benchmarks").join("Cargo.lock")
);
let cargo_lock = rust_root.join("Cargo.lock");
fs::create_dir_all(benchmarks_lock.parent().unwrap()).unwrap();
let content = r#"[[package]]
name = "example-sum-package-name"
version = "0.13.0"
"#;
fs::write(&cargo_lock, content).unwrap();
fs::write(&benchmarks_lock, content).unwrap();
let updated = update_cargo_locks(
&[cargo_lock.as_path(), benchmarks_lock.as_path()],
"example-sum-package-name",
"0.14.0",
)
.unwrap();
assert_eq!(
updated,
vec![
cargo_lock.to_string_lossy().to_string(),
benchmarks_lock.to_string_lossy().to_string(),
]
);
for lock in [&cargo_lock, &benchmarks_lock] {
assert!(
fs::read_to_string(lock)
.unwrap()
.contains("name = \"example-sum-package-name\"\nversion = \"0.14.0\"")
);
}
}
#[test]
fn cargo_locks_update_skips_missing_benchmarks_lock_file() {
let rust_root = temp_dir("locks-no-benchmarks");
let rust_root_str = rust_root.to_string_lossy().to_string();
let benchmarks_lock = get_benchmarks_cargo_lock_path(&rust_root_str);
let cargo_lock = rust_root.join("Cargo.lock");
fs::write(
&cargo_lock,
"[[package]]\nname = \"example-sum-package-name\"\nversion = \"0.13.0\"\n",
)
.unwrap();
let updated = update_cargo_locks(
&[cargo_lock.as_path(), benchmarks_lock.as_path()],
"example-sum-package-name",
"0.14.0",
)
.unwrap();
assert_eq!(updated, vec![cargo_lock.to_string_lossy().to_string()]);
assert!(!benchmarks_lock.exists());
}
#[test]
fn rebase_must_run_before_staging_the_version_bump() {
let repo = temp_dir("rebase-order");
let git = |args: &[&str]| -> std::process::Output {
Command::new("git")
.args(args)
.current_dir(&repo)
.output()
.expect("failed to run git")
};
git(&["init", "-q", "."]);
git(&["checkout", "-q", "-b", "main"]);
git(&["config", "user.email", "ci@example.com"]);
git(&["config", "user.name", "ci"]);
fs::write(repo.join("Cargo.toml"), "version = \"0.1.0\"\n").unwrap();
git(&["add", "Cargo.toml"]);
git(&["commit", "-qm", "A: initial"]);
git(&["checkout", "-q", "-b", "upstream"]);
fs::write(repo.join("remote.txt"), "remote change\n").unwrap();
git(&["add", "remote.txt"]);
git(&["commit", "-qm", "B: remote advanced"]);
git(&["checkout", "-q", "main"]);
fs::write(repo.join("Cargo.toml"), "version = \"0.1.1\"\n").unwrap();
git(&["add", "Cargo.toml"]);
let buggy = git(&["rebase", "upstream"]);
assert!(
!buggy.status.success(),
"rebase unexpectedly succeeded with a dirty index"
);
let buggy_err = String::from_utf8_lossy(&buggy.stderr);
assert!(
buggy_err.contains("cannot rebase") || buggy_err.contains("uncommitted changes"),
"unexpected rebase error: {buggy_err}"
);
let _ = git(&["rebase", "--abort"]);
git(&["reset", "-q", "--hard", "HEAD"]);
let fixed = git(&["rebase", "upstream"]);
assert!(
fixed.status.success(),
"rebase with a clean tree failed: {}",
String::from_utf8_lossy(&fixed.stderr)
);
fs::write(repo.join("Cargo.toml"), "version = \"0.1.1\"\n").unwrap();
git(&["add", "Cargo.toml"]);
let commit = git(&["commit", "-qm", "chore: release 0.1.1"]);
assert!(
commit.status.success(),
"commit after clean rebase failed: {}",
String::from_utf8_lossy(&commit.stderr)
);
let log = git(&["log", "--oneline"]);
let log_out = String::from_utf8_lossy(&log.stdout);
assert!(log_out.contains("release 0.1.1"));
assert!(log_out.contains("B: remote advanced"));
}
#[test]
fn repository_rules_are_not_retried_as_a_lost_race() {
assert_eq!(
classify_push_failure("GH013 repository rule violations [rejected] (fetch first)"),
PushFailure::RepositoryRules
);
assert_eq!(
classify_push_failure("updates were rejected (fetch first)"),
PushFailure::LostRace
);
assert_eq!(
classify_push_failure("authentication failed"),
PushFailure::Other
);
assert_eq!(
classify_push_failure("[rejected] main -> main (shallow update not allowed)"),
PushFailure::Other
);
}