use super::remote::{parse_github_remote, parse_remote_owner_repo, parse_remote_web_base};
use super::semver::{compare_prerelease, parse_semver, parse_semver_tag};
use super::tags::{
create_tag_local_only, filter_ignored_tags, find_latest_tag_matching,
find_latest_tag_matching_with_prefix, find_previous_tag, find_previous_tag_with_prefix,
get_all_semver_tags, is_nightly_tag, list_remote_tag_names_in, strip_monorepo_prefix,
tag_is_signed,
};
use crate::redact::redact_url_credentials;
use crate::test_helpers::CwdGuard;
#[test]
fn test_parse_semver() {
let v = parse_semver("v1.2.3").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 2);
assert_eq!(v.patch, 3);
assert_eq!(v.prerelease, None);
assert_eq!(v.build_metadata, None);
}
#[test]
fn test_parse_semver_prerelease() {
let v = parse_semver("v1.0.0-rc.1").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.prerelease, Some("rc.1".to_string()));
assert_eq!(v.build_metadata, None);
}
#[test]
fn test_parse_semver_build_metadata() {
let v = parse_semver("v1.0.0+build.42").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 0);
assert_eq!(v.patch, 0);
assert_eq!(v.prerelease, None);
assert_eq!(v.build_metadata, Some("build.42".to_string()));
}
#[test]
fn test_parse_semver_prerelease_and_build_metadata() {
let v = parse_semver("v1.0.0-rc.1+build.42").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.prerelease, Some("rc.1".to_string()));
assert_eq!(v.build_metadata, Some("build.42".to_string()));
}
#[test]
fn test_parse_semver_rejects_prefix() {
assert!(parse_semver("cfgd-core-v2.1.0").is_err());
assert!(parse_semver("release-notes-v1.2.3").is_err());
}
#[test]
fn test_parse_semver_tag_with_prefix() {
let v = parse_semver_tag("cfgd-core-v2.1.0").unwrap();
assert_eq!(v.major, 2);
assert_eq!(v.minor, 1);
assert_eq!(v.patch, 0);
}
#[test]
fn test_parse_semver_tag_plain() {
let v = parse_semver_tag("v1.2.3").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 2);
assert_eq!(v.patch, 3);
}
#[test]
fn test_parse_semver_tag_with_prerelease_prefix() {
let v = parse_semver_tag("my-project-v1.0.0-rc.1").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.prerelease, Some("rc.1".to_string()));
}
#[test]
fn test_version_from_tag_extracts_across_tag_families() {
use super::semver::version_from_tag;
assert_eq!(version_from_tag("v1.2.3"), Some("1.2.3".to_string()));
assert_eq!(version_from_tag("1.2.3"), Some("1.2.3".to_string()));
assert_eq!(version_from_tag("crd-v0.5.0"), Some("0.5.0".to_string()));
assert_eq!(
version_from_tag("sub/v1.2.3-rc.1"),
Some("1.2.3-rc.1".to_string())
);
assert_eq!(version_from_tag("core-v2.0.1"), Some("2.0.1".to_string()));
assert_eq!(
version_from_tag("v0.4.0-beta.1"),
Some("0.4.0-beta.1".to_string())
);
assert_eq!(version_from_tag(""), None);
assert_eq!(version_from_tag("nightly"), None);
assert_eq!(version_from_tag("not-a-version"), None);
}
#[test]
fn test_split_tag_family_prefix_and_version() {
use super::semver::split_tag_family;
let (prefix, sv) = split_tag_family("v1.2.3").unwrap();
assert_eq!(prefix, "v");
assert_eq!(sv.version_string(), "1.2.3");
let (prefix, sv) = split_tag_family("crd-v0.5.0").unwrap();
assert_eq!(prefix, "crd-v");
assert_eq!(sv.version_string(), "0.5.0");
let (prefix, sv) = split_tag_family("sub/v1.2.3-rc.1").unwrap();
assert_eq!(prefix, "sub/v");
assert_eq!(sv.version_string(), "1.2.3-rc.1");
let (prefix, _) = split_tag_family("1.2.3").unwrap();
assert_eq!(prefix, "");
assert!(split_tag_family("").is_none());
assert!(split_tag_family("nightly").is_none());
}
#[test]
fn test_is_prerelease() {
assert!(parse_semver("v1.0.0-rc.1").unwrap().is_prerelease());
assert!(!parse_semver("v1.0.0").unwrap().is_prerelease());
assert!(!parse_semver("v1.0.0+build.42").unwrap().is_prerelease());
}
#[test]
fn test_parse_github_remote_https() {
let result = parse_github_remote("https://github.com/tj-smith47/anodizer.git");
assert_eq!(
result,
Some(("tj-smith47".to_string(), "anodizer".to_string()))
);
}
#[test]
fn test_parse_github_remote_https_no_dotgit() {
let result = parse_github_remote("https://github.com/owner/repo");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_github_remote_ssh() {
let result = parse_github_remote("git@github.com:owner/repo.git");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_github_remote_ssh_no_dotgit() {
let result = parse_github_remote("git@github.com:owner/repo");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_github_remote_invalid() {
let result = parse_github_remote("https://gitlab.com/foo/bar.git");
assert_eq!(result, None);
}
#[test]
fn test_parse_github_remote_empty() {
let result = parse_github_remote("");
assert_eq!(result, None);
}
#[test]
fn test_parse_remote_github_https() {
let result = parse_remote_owner_repo("https://github.com/owner/repo.git");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_remote_gitlab_https() {
let result = parse_remote_owner_repo("https://gitlab.com/owner/repo.git");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_remote_gitea_https() {
let result = parse_remote_owner_repo("https://gitea.example.com/myorg/myapp.git");
assert_eq!(result, Some(("myorg".to_string(), "myapp".to_string())));
}
#[test]
fn test_parse_remote_gitlab_nested_group() {
let result = parse_remote_owner_repo("https://gitlab.com/group/subgroup/repo.git");
assert_eq!(
result,
Some(("group/subgroup".to_string(), "repo".to_string()))
);
}
#[test]
fn test_parse_remote_ssh_gitlab() {
let result = parse_remote_owner_repo("git@gitlab.com:owner/repo.git");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_remote_ssh_gitea() {
let result = parse_remote_owner_repo("git@gitea.example.com:org/app.git");
assert_eq!(result, Some(("org".to_string(), "app".to_string())));
}
#[test]
fn test_parse_remote_ssh_nested_group() {
let result = parse_remote_owner_repo("git@gitlab.com:group/subgroup/repo.git");
assert_eq!(
result,
Some(("group/subgroup".to_string(), "repo".to_string()))
);
}
#[test]
fn test_parse_remote_no_dotgit() {
let result = parse_remote_owner_repo("https://gitlab.com/owner/repo");
assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
}
#[test]
fn test_parse_remote_empty() {
assert_eq!(parse_remote_owner_repo(""), None);
}
#[test]
fn test_parse_remote_http() {
let result = parse_remote_owner_repo("http://gitlab.local/team/project.git");
assert_eq!(result, Some(("team".to_string(), "project".to_string())));
}
#[test]
fn test_web_base_github_https() {
assert_eq!(
parse_remote_web_base("https://github.com/owner/repo.git").as_deref(),
Some("https://github.com/owner/repo")
);
}
#[test]
fn test_web_base_github_ssh() {
assert_eq!(
parse_remote_web_base("git@github.com:owner/repo.git").as_deref(),
Some("https://github.com/owner/repo")
);
}
#[test]
fn test_web_base_gitlab_ssh_self_hosted() {
assert_eq!(
parse_remote_web_base("git@gitlab.example.com:team/widget.git").as_deref(),
Some("https://gitlab.example.com/team/widget")
);
}
#[test]
fn test_web_base_gitea_https_nested_group() {
assert_eq!(
parse_remote_web_base("https://gitea.example.com/group/subgroup/repo.git").as_deref(),
Some("https://gitea.example.com/group/subgroup/repo")
);
}
#[test]
fn test_web_base_http_normalized_to_https() {
assert_eq!(
parse_remote_web_base("http://gitlab.local/team/project.git").as_deref(),
Some("https://gitlab.local/team/project")
);
}
#[test]
fn test_web_base_https_strips_userinfo() {
assert_eq!(
parse_remote_web_base("https://user:token@gitlab.com/owner/repo.git").as_deref(),
Some("https://gitlab.com/owner/repo")
);
}
#[test]
fn test_web_base_empty() {
assert_eq!(parse_remote_web_base(""), None);
}
#[test]
fn test_strip_url_credentials_with_userinfo() {
assert_eq!(
redact_url_credentials("https://user:token@github.com/owner/repo.git"),
"https://<redacted>@github.com/owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_no_userinfo() {
assert_eq!(
redact_url_credentials("https://github.com/owner/repo.git"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_ssh_unchanged() {
assert_eq!(
redact_url_credentials("git@github.com:owner/repo.git"),
"git@github.com:owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_user_only() {
assert_eq!(
redact_url_credentials("https://user@github.com/owner/repo.git"),
"https://<redacted>@github.com/owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_token_with_at_sign_does_not_leak() {
let leaky = "https://user:t@k@n@github.com/owner/repo.git";
let scrubbed = redact_url_credentials(leaky);
assert!(!scrubbed.contains("t@k@n"));
assert_eq!(scrubbed, "https://<redacted>@github.com/owner/repo.git");
}
#[test]
fn test_compare_prerelease_numeric() {
assert_eq!(
compare_prerelease("rc.9", "rc.10"),
std::cmp::Ordering::Less
);
assert_eq!(
compare_prerelease("rc.10", "rc.9"),
std::cmp::Ordering::Greater
);
}
#[test]
fn test_compare_prerelease_numeric_less_than_alpha() {
assert_eq!(compare_prerelease("1", "alpha"), std::cmp::Ordering::Less);
assert_eq!(
compare_prerelease("alpha", "1"),
std::cmp::Ordering::Greater
);
}
#[test]
fn test_compare_prerelease_alpha_lexicographic() {
assert_eq!(
compare_prerelease("alpha", "beta"),
std::cmp::Ordering::Less
);
}
#[test]
fn test_compare_prerelease_shorter_lower_precedence() {
assert_eq!(
compare_prerelease("alpha", "alpha.1"),
std::cmp::Ordering::Less
);
}
#[test]
fn test_compare_prerelease_equal() {
assert_eq!(
compare_prerelease("rc.1", "rc.1"),
std::cmp::Ordering::Equal
);
}
#[test]
fn test_semver_ord_prerelease_less_than_release() {
let pre = parse_semver("v1.0.0-rc.1").unwrap();
let rel = parse_semver("v1.0.0").unwrap();
assert!(pre < rel);
}
#[test]
fn test_semver_ord_prerelease_numeric_sorting() {
let rc9 = parse_semver("v1.0.0-rc.9").unwrap();
let rc10 = parse_semver("v1.0.0-rc.10").unwrap();
assert!(rc9 < rc10);
}
#[test]
fn test_semver_build_metadata_ignored_in_ord_and_eq() {
let a = parse_semver("v1.2.3+abc").unwrap();
let b = parse_semver("v1.2.3+def").unwrap();
assert_eq!(a.build_metadata.as_deref(), Some("abc"));
assert_eq!(b.build_metadata.as_deref(), Some("def"));
assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal);
assert_eq!(a, b);
assert_eq!(b.cmp(&a), std::cmp::Ordering::Equal);
let plain = parse_semver("v1.2.3").unwrap();
assert_eq!(plain.cmp(&a), std::cmp::Ordering::Equal);
assert_eq!(plain, a);
let pre_a = parse_semver("v1.0.0-rc.1+build.42").unwrap();
let pre_b = parse_semver("v1.0.0-rc.1+build.99").unwrap();
assert_eq!(pre_a.cmp(&pre_b), std::cmp::Ordering::Equal);
assert_eq!(pre_a, pre_b);
}
use serial_test::serial;
fn init_repo_with_tags(dir: &std::path::Path, tags: &[&str]) {
use std::process::Command;
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.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)
);
};
run(&["init"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "test"]);
std::fs::write(dir.join("README"), "init").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "initial"]);
for tag in tags {
run(&["tag", tag]);
}
}
#[test]
fn is_nightly_tag_matches_minted_shapes_only() {
for t in [
"nightly",
"v0.5.1-9a0d7ed0-nightly",
"operator-v0.5.1-9a0d7ed0-nightly",
"csi-v0.5.1-9a0d7ed0-nightly",
"v1.2.3-nightly.4+abc1234",
"app-v2.0.0-nightly",
"0.107.0-nightly.4+9f9c12c",
"app-nightly-v1.0.0-9a0d7ed0-nightly",
] {
assert!(is_nightly_tag(t), "{t} must be nightly-shaped");
}
for t in [
"v1.2.3",
"core-v0.5.0",
"v0.2.0-beta.3",
"nightly-tools-v1.0.0",
"app-nightly-v1.0.0",
"v1.0.0-rc.1",
"vnightlyish-1.0.0",
] {
assert!(!is_nightly_tag(t), "{t} must NOT be nightly-shaped");
}
}
#[test]
fn filter_ignored_tags_applies_config_and_nightly_exclusion() {
let tags: Vec<String> = [
"v1.0.0",
"operator-v0.5.1-9a0d7ed0-nightly",
"withdrawn-v0.9.9",
"rc-v2.0.0",
]
.iter()
.map(|s| s.to_string())
.collect();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["withdrawn-*".to_string()]),
ignore_tag_prefixes: Some(vec!["rc-".to_string()]),
..Default::default()
};
assert_eq!(
filter_ignored_tags(&tags, Some(&gc), None),
vec!["v1.0.0".to_string()]
);
assert_eq!(
filter_ignored_tags(&tags[..2], None, None),
vec!["v1.0.0".to_string()]
);
}
#[test]
#[serial(cwd, path_env)]
fn find_latest_tag_skips_stranded_nightly_tags_unconditionally() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v0.5.0", "v0.5.1-9a0d7ed0-nightly", "nightly"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
assert_eq!(result, Some("v0.5.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_none_config_unchanged_behavior() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
assert_eq!(result, Some("v2.0.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_get_all_semver_tags_ignore_tags() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v3.0.0".to_string()]),
..Default::default()
};
let tags = get_all_semver_tags("v", Some(&gc), None).unwrap();
assert_eq!(tags, vec!["v2.0.0".to_string(), "v1.0.0".to_string()]);
}
#[test]
#[serial(cwd, path_env)]
fn test_get_all_semver_tags_ignore_tag_prefixes() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "nightly-v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tag_prefixes: Some(vec!["nightly-".to_string()]),
..Default::default()
};
let tags = get_all_semver_tags("", Some(&gc), None).unwrap();
assert_eq!(tags, vec!["v2.0.0".to_string(), "v1.0.0".to_string()]);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_empty_ignore_prefix_excludes_all() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tag_prefixes: Some(vec![String::new()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(
result, None,
"empty ignore_tag_prefixes must exclude every tag in find_latest"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_smartsemver_empty_ignore_prefix_is_skipped() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
ignore_tag_prefixes: Some(vec![String::new()]),
..Default::default()
};
let result = find_previous_tag_with_prefix("v3.0.0", Some(&gc), None, None).unwrap();
assert_eq!(
result,
Some("v2.0.0".to_string()),
"empty ignore_tag_prefixes must be skipped on the smartsemver path"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_smartsemver_skips_candidate_on_the_current_tags_commit() {
use std::process::Command;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v0.1.0"]);
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.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);
};
std::fs::write(dir.join("CHANGE"), "work").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "feat: work"]);
run(&["tag", "v0.2.0"]);
run(&["tag", "v0.3.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag_with_prefix("v0.2.0", Some(&gc), None, None).unwrap();
assert_eq!(
result,
Some("v0.1.0".to_string()),
"a tag on the current tag's own commit must not become the range start"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_smartsemver_keeps_co_located_tag_when_no_other_candidate() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag_with_prefix("v3.0.0", Some(&gc), None, None).unwrap();
assert_eq!(
result,
Some("v2.0.0".to_string()),
"an all-co-located candidate set must still yield the tightest bound"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_get_all_semver_tags_no_config_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let tags = get_all_semver_tags("v", None, None).unwrap();
assert_eq!(tags, vec!["v2.0.0".to_string(), "v1.0.0".to_string()]);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_ignore_tags_exact_match() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v3.0.0".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v2.0.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_ignore_tags_multiple() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v3.0.0".to_string(), "v2.0.0".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v1.0.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_ignore_tag_prefixes() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(
dir,
&["v1.0.0", "v2.0.0", "nightly-v3.0.0", "nightly-v4.0.0"],
);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tag_prefixes: Some(vec!["nightly-".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v2.0.0".to_string()));
let result_nightly = find_latest_tag_matching("nightly-v{{ .Version }}", None, None).unwrap();
assert_eq!(result_nightly, Some("nightly-v4.0.0".to_string()));
let result_filtered =
find_latest_tag_matching("nightly-v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result_filtered, None);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_ignore_all_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v1.0.0".to_string(), "v2.0.0".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, None);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_ignore_tags_and_prefixes_combined() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0-beta.1"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v2.0.0".to_string()]),
ignore_tag_prefixes: Some(vec!["v3".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v1.0.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_with_prefixed_template() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(
dir,
&[
"myapp-v1.0.0",
"myapp-v2.0.0",
"myapp-v3.0.0",
"other-v9.0.0",
],
);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["myapp-v3.0.0".to_string()]),
..Default::default()
};
let result = find_latest_tag_matching("myapp-v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("myapp-v2.0.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_default_git_config_same_as_none() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig::default();
let with_default = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
let with_none = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
assert_eq!(with_default, with_none);
assert_eq!(with_default, Some("v2.0.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_prerelease_suffix_with_default_sort() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v1.1.1-rc.1"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result_no_suffix = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
assert_eq!(
result_no_suffix,
Some("v1.1.1-rc.1".to_string()),
"without prerelease_suffix, SemVer sort puts v1.1.1-rc.1 highest"
);
let gc = crate::config::GitConfig {
prerelease_suffix: Some("-rc".to_string()),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(
result,
Some("v1.1.1-rc.1".to_string()),
"prerelease_suffix activates git-delegated sort; v1.1.1-rc.1 still highest"
);
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = std::process::Command::new("git");
cmd.args(args)
.current_dir(dir)
.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());
};
run(&["tag", "v1.1.1"]);
let result_both = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert!(
result_both.is_some(),
"should find a tag with both release and rc present"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_ignore_tags_template_rendered() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let mut vars = crate::template::TemplateVars::new();
vars.set_env("IGNORE_TAG", "v3.0.0");
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["{{ .Env.IGNORE_TAG }}".to_string()]),
..Default::default()
};
let result_raw = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result_raw, Some("v3.0.0".to_string()));
let result_rendered =
find_latest_tag_matching("v{{ .Version }}", Some(&gc), Some(&vars)).unwrap();
assert_eq!(result_rendered, Some("v2.0.0".to_string()));
}
fn init_repo_with_tagged_commits(dir: &std::path::Path, tags: &[&str]) {
use std::process::Command;
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.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)
);
};
run(&["init"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "test"]);
for (i, tag) in tags.iter().enumerate() {
let filename = format!("file_{}", i);
std::fs::write(dir.join(&filename), format!("content {}", i)).unwrap();
run(&["add", "."]);
run(&["commit", "-m", &format!("commit for {}", tag)]);
run(&["tag", tag]);
}
}
#[test]
#[serial(cwd, path_env)]
fn test_find_previous_tag_with_ignore_tags() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result = find_previous_tag("v3.0.0", None, None).unwrap();
assert_eq!(result, Some("v2.0.0".to_string()));
let gc = crate::config::GitConfig {
ignore_tags: Some(vec!["v2.0.0".to_string()]),
..Default::default()
};
let result_filtered = find_previous_tag("v3.0.0", Some(&gc), None).unwrap();
assert_eq!(result_filtered, Some("v1.0.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_previous_tag_with_ignore_tag_prefixes() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0", "nightly-v2.0.0", "v3.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result = find_previous_tag("v3.0.0", None, None).unwrap();
assert_eq!(result, Some("nightly-v2.0.0".to_string()));
let gc = crate::config::GitConfig {
ignore_tag_prefixes: Some(vec!["nightly-".to_string()]),
..Default::default()
};
let result_filtered = find_previous_tag("v3.0.0", Some(&gc), None).unwrap();
assert_eq!(result_filtered, Some("v1.0.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_previous_tag_no_config_unchanged_behavior() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result = find_previous_tag("v2.0.0", None, None).unwrap();
assert_eq!(result, Some("v1.0.0".to_string()));
}
#[test]
fn test_strip_monorepo_prefix_with_match() {
assert_eq!(
strip_monorepo_prefix("subproject1/v1.2.3", "subproject1/"),
"v1.2.3"
);
}
#[test]
fn test_strip_monorepo_prefix_no_match() {
assert_eq!(strip_monorepo_prefix("v1.2.3", "subproject1/"), "v1.2.3");
}
#[test]
fn test_strip_monorepo_prefix_empty_prefix() {
assert_eq!(strip_monorepo_prefix("v1.2.3", ""), "v1.2.3");
}
#[test]
fn test_strip_monorepo_prefix_partial_match() {
assert_eq!(
strip_monorepo_prefix("subproject1/v1.2.3", "sub"),
"project1/v1.2.3"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_with_monorepo_prefix_filters_and_returns_full_tag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(
dir,
&[
"v1.0.0",
"subproject1/v1.0.0",
"subproject1/v2.0.0",
"subproject2/v3.0.0",
],
);
let _cwd = CwdGuard::new(dir).unwrap();
let result =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("subproject1/"))
.unwrap();
assert_eq!(
result,
Some("subproject1/v2.0.0".to_string()),
"should return the full tag with prefix"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_with_monorepo_prefix_semver_comparison_uses_stripped_tag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["myapp/v1.0.0", "myapp/v2.0.0", "myapp/v1.5.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("myapp/"))
.unwrap();
assert_eq!(
result,
Some("myapp/v2.0.0".to_string()),
"should pick the highest version based on stripped semver"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_with_monorepo_prefix_no_matching_tags() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("myapp/"))
.unwrap();
assert_eq!(result, None);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_with_monorepo_prefix_none_behaves_like_original() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v2.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result_with_prefix =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, None).unwrap();
let result_original = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
assert_eq!(result_with_prefix, result_original);
assert_eq!(result_with_prefix, Some("v2.0.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_with_monorepo_prefix_and_prerelease() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["svc/v1.0.0", "svc/v1.1.0-rc.1", "svc/v1.1.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let result =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("svc/")).unwrap();
assert_eq!(
result,
Some("svc/v1.1.0".to_string()),
"release v1.1.0 should win over v1.1.0-rc.1"
);
}
use super::commits::{add_path_in, commit_in};
#[test]
#[serial(token_env)]
fn test_add_path_in_bail_redacts_token_in_stderr() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &[]);
let secret = "ghp_addpathintestSentinel_123456789";
let prev = std::env::var("GITHUB_TOKEN").ok();
unsafe {
std::env::set_var("GITHUB_TOKEN", secret);
}
let nonexistent = dir.join(format!("missing-{secret}.txt"));
let rel = nonexistent.strip_prefix(dir).unwrap();
let err = add_path_in(dir, rel).expect_err("git add must fail on a non-existent path");
let msg = format!("{err:#}");
unsafe {
if let Some(prev) = prev {
std::env::set_var("GITHUB_TOKEN", prev);
} else {
std::env::remove_var("GITHUB_TOKEN");
}
}
assert!(
!msg.contains(secret),
"add_path_in bail leaked GITHUB_TOKEN: {msg}"
);
assert!(
msg.contains("$GITHUB_TOKEN"),
"redaction must substitute $GITHUB_TOKEN: {msg}"
);
}
#[test]
#[serial(token_env)]
fn test_commit_in_bail_redacts_token_in_stderr() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &[]);
let secret = "ghp_commitintestSentinel_987654321";
let prev = std::env::var("GITHUB_TOKEN").ok();
unsafe {
std::env::set_var("GITHUB_TOKEN", secret);
}
let msg_with_secret = format!("release {secret}");
let err = commit_in(dir, &msg_with_secret, false)
.expect_err("commit must fail when nothing is staged");
let msg = format!("{err:#}");
unsafe {
if let Some(prev) = prev {
std::env::set_var("GITHUB_TOKEN", prev);
} else {
std::env::remove_var("GITHUB_TOKEN");
}
}
assert!(
!msg.contains(secret),
"commit_in bail leaked GITHUB_TOKEN: {msg}"
);
}
#[test]
fn test_detect_github_repo_error_strips_url_credentials() {
let leaky = "https://ghp_leakytoken@gitlab.example.com/grp/proj.git";
let scrubbed = redact_url_credentials(leaky);
assert!(!scrubbed.contains("ghp_leakytoken"));
assert_eq!(
scrubbed,
"https://<redacted>@gitlab.example.com/grp/proj.git"
);
}
#[test]
fn short_commit_str_truncates_to_seven_chars_to_match_git_short() {
use super::commits::{SHORT_COMMIT_LEN, short_commit_str};
assert_eq!(SHORT_COMMIT_LEN, 7);
let full = "deadbeef1234567890abcdef";
let short = short_commit_str(full);
assert_eq!(short.len(), 7);
assert_eq!(short, "deadbee");
}
#[test]
fn short_commit_str_passes_short_inputs_through_unchanged() {
use super::commits::short_commit_str;
assert_eq!(short_commit_str("abc"), "abc");
assert_eq!(short_commit_str("abc1234"), "abc1234");
assert_eq!(short_commit_str(""), "");
}
#[test]
fn head_is_at_tag_returns_true_when_head_has_tag() {
use super::tags::head_is_at_tag;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0"]);
assert!(
head_is_at_tag(dir).unwrap(),
"HEAD has tag v1.0.0 attached; head_is_at_tag should return true"
);
}
#[test]
fn head_is_at_tag_returns_false_when_head_has_no_tag() {
use super::tags::head_is_at_tag;
use std::process::Command;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0"]);
std::fs::write(dir.join("untagged.txt"), "no tag here").unwrap();
anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.args(["add", "."]);
cmd
},
"git",
);
anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.args(["commit", "-m", "post-tag commit"]);
cmd
},
"git",
);
assert!(
!head_is_at_tag(dir).unwrap(),
"HEAD is one commit past v1.0.0; head_is_at_tag should return false"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_semver_mode_orders_by_semver() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0-rc.1", "v1.1.0", "v1.2.0-beta.1"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("semver".to_string()),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v1.2.0-beta.1".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_semver_mode_ignores_prerelease_suffix_setting() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0-rc.1", "v1.1.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("semver".to_string()),
prerelease_suffix: Some("-rc".to_string()),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v1.1.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_smartsemver_returns_semver_highest() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0-rc.1", "v1.1.0", "v1.2.0-beta.1"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
assert_eq!(result, Some("v1.2.0-beta.1".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_latest_tag_smartsemver_keeps_prereleases_for_prerelease_target() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tags(dir, &["v1.0.0", "v1.1.0-rc.1", "v1.1.0", "v1.2.0-beta.1"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let mut vars = crate::template::TemplateVars::new();
vars.set("Version", "v1.2.0-beta.2");
let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), Some(&vars)).unwrap();
assert_eq!(
result,
Some("v1.2.0-beta.1".to_string()),
"smartsemver with prerelease target keeps all candidates"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_previous_tag_smartsemver_rc1_classified_as_prerelease() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v1.0.0", "v1.1.0-rc1", "v1.1.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag("v1.1.0", Some(&gc), None).unwrap();
assert_eq!(
result,
Some("v1.0.0".to_string()),
"v1.1.0-rc1 must be classified as prerelease via SemVer parsing alone"
);
let sv = parse_semver_tag("v1.1.0-rc1").unwrap();
assert!(sv.is_prerelease());
}
#[test]
#[serial(cwd, path_env)]
fn test_find_previous_tag_smartsemver_skips_prerelease_predecessor() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v0.1.0", "v0.2.0-beta.3", "v0.2.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag("v0.2.0", Some(&gc), None).unwrap();
assert_eq!(result, Some("v0.1.0".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_previous_tag_smartsemver_keeps_prerelease_when_current_is_prerelease() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v0.1.0", "v0.2.0-beta.1", "v0.2.0-beta.2"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag("v0.2.0-beta.2", Some(&gc), None).unwrap();
assert_eq!(result, Some("v0.2.0-beta.1".to_string()));
}
#[test]
#[serial(cwd, path_env)]
fn test_find_previous_tag_smartsemver_release_tag_filters_prereleases() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v0.1.0", "v0.2.0-beta.3", "v0.2.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag("v0.2.0", Some(&gc), None).unwrap();
assert_eq!(
result,
Some("v0.1.0".to_string()),
"smartsemver must skip v0.2.0-beta.3 with current_tag v0.2.0"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_previous_tag_smartsemver_monorepo_prefix() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["svc/v0.1.0", "svc/v0.2.0-beta.3", "svc/v0.2.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result =
find_previous_tag_with_prefix("svc/v0.2.0", Some(&gc), None, Some("svc/")).unwrap();
assert_eq!(
result,
Some("svc/v0.1.0".to_string()),
"smartsemver must skip svc/v0.2.0-beta.3 in monorepo mode"
);
}
#[test]
#[serial(cwd, path_env)]
fn test_find_previous_tag_smartsemver_early_dev_no_panic() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_tagged_commits(dir, &["v0.0.0"]);
let _cwd = CwdGuard::new(dir).unwrap();
let gc = crate::config::GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
};
let result = find_previous_tag("v0.0.0", Some(&gc), None).unwrap();
assert_eq!(result, None);
}
#[test]
#[serial(path_env)]
fn list_remote_tag_names_dedupes_peeled_annotated_entries() {
use std::process::Command;
let work = tempfile::tempdir().unwrap();
init_repo_with_tags(work.path(), &["v1.0.0"]);
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(work.path())
.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)
);
};
run(&["tag", "-a", "v1.1.0", "-m", "release v1.1.0"]);
let bare = tempfile::tempdir().unwrap();
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["init", "--bare", "-q"]).arg(bare.path());
cmd
},
"git",
);
assert!(out.status.success(), "git init --bare failed");
run(&["remote", "add", "origin", bare.path().to_str().unwrap()]);
run(&["push", "origin", "v1.0.0", "v1.1.0"]);
let mut names = list_remote_tag_names_in(work.path(), "origin").unwrap();
names.sort();
assert_eq!(names, vec!["v1.0.0".to_string(), "v1.1.0".to_string()]);
run(&[
"remote",
"set-url",
"origin",
"/nonexistent/never-a-repo.git",
]);
assert!(list_remote_tag_names_in(work.path(), "origin").is_err());
}
fn init_repo_with_ssh_signing(dir: &std::path::Path) -> tempfile::TempDir {
use std::process::Command;
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.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)
);
};
run(&["init"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "test"]);
std::fs::write(dir.join("README"), "init").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "initial"]);
let keydir = tempfile::tempdir().unwrap();
let key_path = keydir.path().join("sign_key");
let keygen = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("ssh-keygen");
cmd.args(["-t", "ed25519", "-N", "", "-C", "anodizer-test", "-f"])
.arg(&key_path);
cmd
},
"ssh-keygen",
);
assert!(
keygen.status.success(),
"ssh-keygen failed: {}",
String::from_utf8_lossy(&keygen.stderr)
);
let pub_path = format!("{}.pub", key_path.display());
run(&["config", "gpg.format", "ssh"]);
run(&["config", "user.signingkey", &pub_path]);
keydir
}
#[test]
#[serial(path_env)]
fn create_tag_local_only_upgrades_unsigned_reuse_to_signed() {
let tmp = tempfile::tempdir().unwrap();
let _keydir = init_repo_with_ssh_signing(tmp.path());
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let seeded = create_tag_local_only(tmp.path(), "v1.0.0", "seed", false, false, &log);
assert!(seeded.is_ok(), "seeding unsigned tag failed: {seeded:?}");
assert!(
!tag_is_signed(tmp.path(), "v1.0.0").unwrap(),
"precondition: seeded tag must be unsigned"
);
let re = create_tag_local_only(tmp.path(), "v1.0.0", "seed", false, true, &log);
assert!(re.is_ok(), "signed re-run failed: {re:?}");
assert!(
tag_is_signed(tmp.path(), "v1.0.0").unwrap(),
"reuse path must UPGRADE the unsigned tag to a signed one"
);
}
#[test]
#[serial(path_env)]
fn create_tag_local_only_reuses_already_signed_tag() {
let tmp = tempfile::tempdir().unwrap();
let _keydir = init_repo_with_ssh_signing(tmp.path());
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let first = create_tag_local_only(tmp.path(), "v1.0.0", "seed", false, true, &log);
assert!(first.is_ok(), "first signed create failed: {first:?}");
assert!(tag_is_signed(tmp.path(), "v1.0.0").unwrap());
let again = create_tag_local_only(tmp.path(), "v1.0.0", "seed", false, true, &log);
assert!(again.is_ok(), "signed reuse failed: {again:?}");
assert!(
tag_is_signed(tmp.path(), "v1.0.0").unwrap(),
"already-signed tag must remain signed on reuse"
);
}
fn tags_run_git(dir: &std::path::Path, args: &[&str]) {
use std::process::Command;
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.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 tags_init_commit_repo(dir: &std::path::Path) {
tags_run_git(dir, &["init", "-q", "-b", "main"]);
tags_run_git(dir, &["config", "user.email", "test@test.com"]);
tags_run_git(dir, &["config", "user.name", "test"]);
std::fs::write(dir.join("README"), "init").unwrap();
tags_run_git(dir, &["add", "."]);
tags_run_git(dir, &["commit", "-q", "-m", "initial"]);
}
fn tags_add_bare_origin(dir: &std::path::Path) -> tempfile::TempDir {
let bare = tempfile::tempdir().unwrap();
tags_run_git(bare.path(), &["init", "--bare", "-q"]);
tags_run_git(
dir,
&["remote", "add", "origin", bare.path().to_str().unwrap()],
);
bare
}
fn tags_quiet_log() -> crate::log::StageLogger {
crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet)
}
#[test]
fn get_branch_semver_tags_in_excludes_tags_unmerged_into_head() {
use super::tags::get_branch_semver_tags_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
tags_run_git(tmp.path(), &["tag", "v1.0.0"]);
tags_run_git(tmp.path(), &["checkout", "-q", "-b", "feature"]);
std::fs::write(tmp.path().join("f"), "x").unwrap();
tags_run_git(tmp.path(), &["add", "."]);
tags_run_git(tmp.path(), &["commit", "-q", "-m", "feature"]);
tags_run_git(tmp.path(), &["tag", "v9.9.9"]);
tags_run_git(tmp.path(), &["checkout", "-q", "main"]);
let tags = get_branch_semver_tags_in(tmp.path(), "v", None, None).unwrap();
assert_eq!(
tags,
vec!["v1.0.0".to_string()],
"only tags merged into HEAD are returned; v9.9.9 is on an unmerged branch"
);
}
#[test]
fn create_and_push_tag_in_creates_tag_and_warns_without_origin() {
use super::tags::create_and_push_tag_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let log = tags_quiet_log();
create_and_push_tag_in(tmp.path(), "v1.0.0", "rel", false, false, &log, false)
.expect("tag creation must succeed without an origin in non-strict mode");
let out = super::git_output_in(tmp.path(), &["tag", "--list"]).unwrap();
assert!(
out.lines().any(|l| l == "v1.0.0"),
"the annotated tag must exist locally; got {out:?}"
);
}
#[test]
fn create_and_push_tag_in_pushes_to_origin() {
use super::tags::{create_and_push_tag_in, list_remote_tag_names_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let _bare = tags_add_bare_origin(tmp.path());
let log = tags_quiet_log();
create_and_push_tag_in(tmp.path(), "v2.0.0", "rel", false, false, &log, true)
.expect("push to a present origin must succeed");
let names = list_remote_tag_names_in(tmp.path(), "origin").unwrap();
assert!(
names.contains(&"v2.0.0".to_string()),
"the tag must land on origin; got {names:?}"
);
}
#[test]
fn create_and_push_tag_in_strict_bails_without_origin() {
use super::tags::create_and_push_tag_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let log = tags_quiet_log();
let err = create_and_push_tag_in(tmp.path(), "v3.0.0", "rel", false, false, &log, true)
.expect_err("strict mode with no origin must error");
assert!(
format!("{err:#}").contains("no 'origin' remote"),
"strict-mode error must name the missing origin: {err:#}"
);
}
#[test]
fn delete_local_tag_in_is_idempotent_and_removes_present_tags() {
use super::tags::delete_local_tag_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
tags_run_git(tmp.path(), &["tag", "v1.0.0"]);
delete_local_tag_in(tmp.path(), "v1.0.0").expect("deleting a present tag succeeds");
let out = super::git_output_in(tmp.path(), &["tag", "--list"]).unwrap();
assert!(!out.lines().any(|l| l == "v1.0.0"), "tag must be gone");
delete_local_tag_in(tmp.path(), "v1.0.0")
.expect("deleting an already-absent tag must be idempotent, not an error");
}
#[test]
fn delete_remote_tag_in_removes_a_present_remote_tag() {
use super::tags::{delete_remote_tag_in, list_remote_tag_names_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let _bare = tags_add_bare_origin(tmp.path());
tags_run_git(tmp.path(), &["tag", "v1.0.0"]);
tags_run_git(tmp.path(), &["push", "-q", "origin", "v1.0.0"]);
assert!(
list_remote_tag_names_in(tmp.path(), "origin")
.unwrap()
.contains(&"v1.0.0".to_string()),
"precondition: the tag is on origin"
);
delete_remote_tag_in(tmp.path(), "v1.0.0").expect("deleting a present remote tag succeeds");
assert!(
!list_remote_tag_names_in(tmp.path(), "origin")
.unwrap()
.contains(&"v1.0.0".to_string()),
"the tag must be gone from origin after delete"
);
}
#[test]
fn delete_remote_tag_in_bails_without_origin() {
use super::tags::delete_remote_tag_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let err = delete_remote_tag_in(tmp.path(), "v1.0.0")
.expect_err("a push with no origin remote must error");
assert!(
format!("{err:#}").contains("git push origin"),
"error must surface the failed push: {err:#}"
);
}
#[test]
fn get_tags_at_sha_in_returns_empty_on_unknown_sha() {
use super::tags::get_tags_at_sha_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let tags = get_tags_at_sha_in(tmp.path(), "bad!!ref")
.expect("a malformed revision yields no tags, not an error");
assert!(
tags.is_empty(),
"malformed revision must yield no tags; got {tags:?}"
);
}
#[test]
fn push_branch_and_tags_atomic_in_dry_run_pushes_nothing() {
use super::tags::{AtomicPushSpec, push_branch_and_tags_atomic_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
tags_run_git(tmp.path(), &["tag", "v1.0.0"]);
let _bare = tags_add_bare_origin(tmp.path());
let log = tags_quiet_log();
let tags = vec!["v1.0.0".to_string()];
push_branch_and_tags_atomic_in(
tmp.path(),
&AtomicPushSpec {
remote: "origin",
branch: Some("main"),
tags: &tags,
dry_run: true,
strict: false,
},
&log,
)
.expect("dry-run push is a no-op");
let names = super::tags::list_remote_tag_names_in(tmp.path(), "origin").unwrap();
assert!(
names.is_empty(),
"dry-run must push nothing to origin; got {names:?}"
);
}
#[test]
fn push_branch_and_tags_atomic_in_lands_branch_and_tags_on_origin() {
use super::tags::{AtomicPushSpec, list_remote_tag_names_in, push_branch_and_tags_atomic_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
tags_run_git(tmp.path(), &["tag", "v1.0.0"]);
let bare = tags_add_bare_origin(tmp.path());
let log = tags_quiet_log();
let tags = vec!["v1.0.0".to_string()];
push_branch_and_tags_atomic_in(
tmp.path(),
&AtomicPushSpec {
remote: "origin",
branch: Some("main"),
tags: &tags,
dry_run: false,
strict: true,
},
&log,
)
.expect("atomic branch+tag push to a present origin must succeed");
let names = list_remote_tag_names_in(tmp.path(), "origin").unwrap();
assert!(
names.contains(&"v1.0.0".to_string()),
"the tag must land on origin; got {names:?}"
);
super::git_output_in(bare.path(), &["show-ref", "--verify", "refs/heads/main"])
.expect("refs/heads/main must exist on origin after the atomic push");
}
#[test]
fn push_branch_and_tags_atomic_in_nothing_to_push_is_noop() {
use super::tags::{AtomicPushSpec, push_branch_and_tags_atomic_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let log = tags_quiet_log();
push_branch_and_tags_atomic_in(
tmp.path(),
&AtomicPushSpec {
remote: "origin",
branch: None,
tags: &[],
dry_run: false,
strict: true,
},
&log,
)
.expect("nothing-to-push short-circuits before the remote check");
}
#[test]
fn push_branch_and_tags_atomic_in_branch_only_empty_tags_pushes_branch() {
use super::tags::{AtomicPushSpec, push_branch_and_tags_atomic_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let bare = tags_add_bare_origin(tmp.path());
let log = tags_quiet_log();
push_branch_and_tags_atomic_in(
tmp.path(),
&AtomicPushSpec {
remote: "origin",
branch: Some("main"),
tags: &[],
dry_run: false,
strict: true,
},
&log,
)
.expect("branch-only push must succeed");
super::git_output_in(bare.path(), &["show-ref", "--verify", "refs/heads/main"])
.expect("refs/heads/main must exist on origin after the branch-only push");
}
#[test]
fn push_branch_and_tags_atomic_in_strict_bails_without_remote() {
use super::tags::{AtomicPushSpec, push_branch_and_tags_atomic_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let log = tags_quiet_log();
let tags = vec!["v1.0.0".to_string()];
tags_run_git(tmp.path(), &["tag", "v1.0.0"]);
let err = push_branch_and_tags_atomic_in(
tmp.path(),
&AtomicPushSpec {
remote: "origin",
branch: Some("main"),
tags: &tags,
dry_run: false,
strict: true,
},
&log,
)
.expect_err("strict push with no remote must error");
assert!(
format!("{err:#}").contains("no 'origin' remote"),
"strict-mode error must name the missing remote: {err:#}"
);
}
#[test]
fn find_previous_tag_in_returns_none_when_repo_has_no_tags() {
use super::tags::find_previous_tag_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let prev = find_previous_tag_in(tmp.path(), "v1.0.0", None, None).unwrap();
assert_eq!(prev, None, "a tagless repo has no previous tag");
}
#[test]
fn get_first_commit_in_returns_the_root_commit() {
use super::tags::get_first_commit_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
std::fs::write(tmp.path().join("b"), "x").unwrap();
tags_run_git(tmp.path(), &["add", "."]);
tags_run_git(tmp.path(), &["commit", "-q", "-m", "second"]);
let root = get_first_commit_in(tmp.path()).unwrap();
let expected = super::git_output_in(tmp.path(), &["rev-list", "--max-parents=0", "HEAD"])
.unwrap()
.lines()
.last()
.unwrap()
.to_string();
assert_eq!(
root, expected,
"must return the repository's root commit sha"
);
}
#[test]
fn tag_points_at_head_in_true_for_head_tag_false_otherwise() {
use super::tags::tag_points_at_head_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
tags_run_git(tmp.path(), &["tag", "at-head"]);
std::fs::write(tmp.path().join("b"), "x").unwrap();
tags_run_git(tmp.path(), &["add", "."]);
tags_run_git(tmp.path(), &["commit", "-q", "-m", "second"]);
tags_run_git(tmp.path(), &["tag", "at-head-2"]);
assert!(
tag_points_at_head_in(tmp.path(), "at-head-2").unwrap(),
"a tag on HEAD must report true"
);
assert!(
!tag_points_at_head_in(tmp.path(), "at-head").unwrap(),
"a tag on an earlier commit must report false"
);
assert!(
tag_points_at_head_in(tmp.path(), "no-such-tag").is_err(),
"a tag that does not resolve must error, not read as 'points elsewhere'"
);
}
#[test]
fn tag_position_in_distinguishes_missing_at_head_ancestor_and_unrelated() {
use super::tags::{TagPosition, tag_position_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
tags_run_git(tmp.path(), &["tag", "released"]);
std::fs::write(tmp.path().join("b"), "x").unwrap();
tags_run_git(tmp.path(), &["add", "."]);
tags_run_git(tmp.path(), &["commit", "-q", "-m", "second"]);
tags_run_git(tmp.path(), &["tag", "current"]);
assert_eq!(
tag_position_in(tmp.path(), "never-cut").unwrap(),
TagPosition::Missing
);
assert_eq!(
tag_position_in(tmp.path(), "current").unwrap(),
TagPosition::AtHead
);
assert_eq!(
tag_position_in(tmp.path(), "released").unwrap(),
TagPosition::AncestorOfHead
);
tags_run_git(tmp.path(), &["reset", "--hard", "-q", "HEAD~1"]);
assert_eq!(
tag_position_in(tmp.path(), "current").unwrap(),
TagPosition::UnrelatedToHead
);
}
#[test]
fn tag_position_in_derefs_annotated_tags() {
use super::tags::{TagPosition, tag_position_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
tags_run_git(tmp.path(), &["tag", "-a", "-m", "released", "ann-released"]);
std::fs::write(tmp.path().join("b"), "x").unwrap();
tags_run_git(tmp.path(), &["add", "."]);
tags_run_git(tmp.path(), &["commit", "-q", "-m", "second"]);
tags_run_git(tmp.path(), &["tag", "-a", "-m", "current", "ann-current"]);
let tag_obj =
anodizer_core::test_helpers::git_test_stdout(tmp.path(), &["rev-parse", "ann-current"]);
let head = anodizer_core::test_helpers::git_test_stdout(tmp.path(), &["rev-parse", "HEAD"]);
assert_ne!(
tag_obj, head,
"an annotated tag ref must name a tag object, else this fixture proves nothing"
);
assert_eq!(
tag_position_in(tmp.path(), "ann-current").unwrap(),
TagPosition::AtHead
);
assert_eq!(
tag_position_in(tmp.path(), "ann-released").unwrap(),
TagPosition::AncestorOfHead
);
}
#[test]
fn tag_position_in_handles_crate_prefixed_and_slashed_tags() {
use super::tags::{TagPosition, tag_position_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
tags_run_git(tmp.path(), &["tag", "core-v0.3.2"]);
tags_run_git(tmp.path(), &["tag", "subproject1/v1.2.3"]);
std::fs::write(tmp.path().join("b"), "x").unwrap();
tags_run_git(tmp.path(), &["add", "."]);
tags_run_git(tmp.path(), &["commit", "-q", "-m", "second"]);
tags_run_git(tmp.path(), &["tag", "cli-v0.4.0"]);
assert_eq!(
tag_position_in(tmp.path(), "cli-v0.4.0").unwrap(),
TagPosition::AtHead
);
assert_eq!(
tag_position_in(tmp.path(), "core-v0.3.2").unwrap(),
TagPosition::AncestorOfHead
);
assert_eq!(
tag_position_in(tmp.path(), "subproject1/v1.2.3").unwrap(),
TagPosition::AncestorOfHead
);
assert_eq!(
tag_position_in(tmp.path(), "core-v9.9.9").unwrap(),
TagPosition::Missing
);
}
#[test]
fn tag_position_in_answers_on_a_detached_head() {
use super::tags::{TagPosition, tag_position_in};
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
tags_run_git(tmp.path(), &["tag", "v0.1.0"]);
std::fs::write(tmp.path().join("b"), "x").unwrap();
tags_run_git(tmp.path(), &["add", "."]);
tags_run_git(tmp.path(), &["commit", "-q", "-m", "second"]);
tags_run_git(tmp.path(), &["tag", "v0.2.0"]);
tags_run_git(tmp.path(), &["checkout", "-q", "--detach", "v0.1.0"]);
assert_eq!(
anodizer_core::test_helpers::git_test_stdout(
tmp.path(),
&["rev-parse", "--abbrev-ref", "HEAD"]
),
"HEAD",
"fixture must actually be detached"
);
assert_eq!(
tag_position_in(tmp.path(), "v0.1.0").unwrap(),
TagPosition::AtHead
);
assert_eq!(
tag_position_in(tmp.path(), "v0.2.0").unwrap(),
TagPosition::UnrelatedToHead
);
}
#[test]
fn tag_position_in_errors_when_the_reachability_query_fails() {
use super::tags::tag_position_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
let tree =
anodizer_core::test_helpers::git_test_stdout(tmp.path(), &["rev-parse", "HEAD^{tree}"]);
tags_run_git(tmp.path(), &["tag", "tree-tag", &tree]);
let err = tag_position_in(tmp.path(), "tree-tag")
.expect_err("a failed reachability query must not read as a position");
let msg = format!("{err:#}");
assert!(
msg.contains("merge-base --is-ancestor") && msg.contains("not a commit"),
"the error must carry git's own diagnosis: {msg}"
);
}
#[test]
fn get_tags_at_head_in_lists_only_head_tags() {
use super::tags::get_tags_at_head_in;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
tags_run_git(tmp.path(), &["tag", "old"]);
std::fs::write(tmp.path().join("b"), "x").unwrap();
tags_run_git(tmp.path(), &["add", "."]);
tags_run_git(tmp.path(), &["commit", "-q", "-m", "second"]);
tags_run_git(tmp.path(), &["tag", "new"]);
let at_head = get_tags_at_head_in(tmp.path()).unwrap();
assert_eq!(
at_head,
vec!["new".to_string()],
"only the tag on HEAD is returned, not the one on the earlier commit"
);
}
#[test]
fn list_tags_with_prefix_filters_and_sorts_by_reverse_semver() {
use super::tags::list_tags_with_prefix;
let tmp = tempfile::tempdir().unwrap();
tags_init_commit_repo(tmp.path());
for t in ["v1.0.0", "v1.2.0", "v2.0.0", "other-1"] {
tags_run_git(tmp.path(), &["tag", t]);
}
let tags = list_tags_with_prefix(tmp.path(), "v").unwrap();
assert_eq!(
tags,
vec![
"v2.0.0".to_string(),
"v1.2.0".to_string(),
"v1.0.0".to_string()
],
);
}