use anyhow::{Context as _, Result, bail};
use regex::Regex;
use std::process::Command;
use std::sync::LazyLock;
use crate::config::GitConfig;
use crate::template::TemplateVars;
pub fn render_ignore_patterns(
git_config: Option<&GitConfig>,
vars: Option<&TemplateVars>,
) -> (Vec<String>, Vec<String>) {
let rendered_tags: Vec<String> = git_config
.and_then(|gc| gc.ignore_tags.as_ref())
.map(|v| {
v.iter()
.map(|s| {
if let Some(tv) = vars {
crate::template::render(s, tv).unwrap_or_else(|_| s.clone())
} else {
s.clone()
}
})
.collect()
})
.unwrap_or_default();
let rendered_prefixes: Vec<String> = git_config
.and_then(|gc| gc.ignore_tag_prefixes.as_ref())
.map(|v| {
v.iter()
.map(|s| {
if let Some(tv) = vars {
crate::template::render(s, tv).unwrap_or_else(|_| s.clone())
} else {
s.clone()
}
})
.collect()
})
.unwrap_or_default();
(rendered_tags, rendered_prefixes)
}
#[derive(Debug, Clone)]
pub struct SemVer {
pub major: u64,
pub minor: u64,
pub patch: u64,
pub prerelease: Option<String>,
pub build_metadata: Option<String>,
}
impl SemVer {
pub fn is_prerelease(&self) -> bool {
self.prerelease.is_some()
}
}
impl PartialEq for SemVer {
fn eq(&self, other: &Self) -> bool {
self.major == other.major
&& self.minor == other.minor
&& self.patch == other.patch
&& self.prerelease == other.prerelease
}
}
impl Eq for SemVer {}
impl PartialOrd for SemVer {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SemVer {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.major
.cmp(&other.major)
.then(self.minor.cmp(&other.minor))
.then(self.patch.cmp(&other.patch))
.then(match (&self.prerelease, &other.prerelease) {
(Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (Some(a), Some(b)) => compare_prerelease(a, b),
(None, None) => std::cmp::Ordering::Equal,
})
}
}
fn compare_prerelease(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let a_ids: Vec<&str> = a.split('.').collect();
let b_ids: Vec<&str> = b.split('.').collect();
for (ai, bi) in a_ids.iter().zip(b_ids.iter()) {
let ord = match (ai.parse::<u64>(), bi.parse::<u64>()) {
(Ok(an), Ok(bn)) => an.cmp(&bn), (Ok(_), Err(_)) => Ordering::Less, (Err(_), Ok(_)) => Ordering::Greater, (Err(_), Err(_)) => ai.cmp(bi), };
if ord != Ordering::Equal {
return ord;
}
}
a_ids.len().cmp(&b_ids.len())
}
static SEMVER_RE: LazyLock<Regex> =
LazyLock::new(|| crate::util::static_regex(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(.+))?$"));
pub fn parse_semver(tag: &str) -> Result<SemVer> {
let caps = SEMVER_RE
.captures(tag)
.ok_or_else(|| anyhow::anyhow!("not a valid semver tag: {}", tag))?;
Ok(SemVer {
major: caps[1].parse()?,
minor: caps[2].parse()?,
patch: caps[3].parse()?,
prerelease: caps.get(4).map(|m| m.as_str().to_string()),
build_metadata: caps.get(5).map(|m| m.as_str().to_string()),
})
}
pub fn parse_semver_tag(tag: &str) -> Result<SemVer> {
if let Ok(sv) = parse_semver(tag) {
return Ok(sv);
}
static PREFIX_RE: LazyLock<Regex> =
LazyLock::new(|| crate::util::static_regex(r"[-_/](v?\d+\.\d+\.\d+(?:-[^+]+)?(?:\+.+)?)$"));
if let Some(caps) = PREFIX_RE.captures(tag) {
return parse_semver(&caps[1]);
}
anyhow::bail!("not a valid semver tag: {}", tag)
}
#[derive(Debug, Clone)]
pub struct GitInfo {
pub tag: String,
pub commit: String,
pub short_commit: String,
pub branch: String,
pub dirty: bool,
pub semver: SemVer,
pub commit_date: String,
pub commit_timestamp: String,
pub previous_tag: Option<String>,
pub remote_url: String,
pub summary: String,
pub tag_subject: String,
pub tag_contents: String,
pub tag_body: String,
pub first_commit: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Commit {
pub hash: String,
pub short_hash: String,
pub message: String,
pub author_name: String,
pub author_email: String,
pub body: String,
}
fn git_output(args: &[&str]) -> Result<String> {
let output = Command::new("git").args(args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git {} failed: {}", args.join(" "), stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn is_git_dirty() -> bool {
git_output(&["status", "--porcelain"])
.map(|s| !s.is_empty())
.unwrap_or(false)
}
pub fn local_git_user_name() -> Option<String> {
git_output(&["config", "user.name"])
.ok()
.filter(|s| !s.is_empty())
}
pub fn local_git_user_email() -> Option<String> {
git_output(&["config", "user.email"])
.ok()
.filter(|s| !s.is_empty())
}
fn strip_url_credentials(url: &str) -> String {
if let Some(rest) = url.strip_prefix("https://")
&& let Some(at_pos) = rest.find('@')
{
return format!("https://{}", &rest[at_pos + 1..]);
}
url.to_string()
}
pub fn detect_git_info(tag: &str, skip_validate: bool) -> Result<GitInfo> {
if !is_git_repo() {
return Ok(GitInfo {
tag: tag.to_string(),
commit: String::new(),
short_commit: String::new(),
branch: String::new(),
dirty: false,
semver: SemVer {
major: 0,
minor: 0,
patch: 0,
prerelease: None,
build_metadata: None,
},
commit_date: String::new(),
commit_timestamp: String::new(),
previous_tag: None,
remote_url: String::new(),
summary: String::new(),
tag_subject: String::new(),
tag_contents: String::new(),
tag_body: String::new(),
first_commit: None,
});
}
let commit = git_output(&["rev-parse", "HEAD"])?;
let short_commit = git_output(&["rev-parse", "--short", "HEAD"])?;
let branch = git_output(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default();
let dirty = is_git_dirty();
let commit_date = git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%cI"])
.unwrap_or_default();
let commit_timestamp =
git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%at"])
.unwrap_or_default();
let remote_url_raw = git_output(&["ls-remote", "--get-url"]).unwrap_or_default();
let remote_url = strip_url_credentials(&remote_url_raw);
let summary = git_output(&[
"-c",
"log.showSignature=false",
"describe",
"--tags",
"--always",
"--dirty",
])
.unwrap_or_default();
let tag_subject = git_output(&["tag", "-l", "--format=%(contents:subject)", tag])
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%s"])
.unwrap_or_default()
});
let tag_contents = git_output(&["tag", "-l", "--format=%(contents)", tag])
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%B"])
.unwrap_or_default()
});
let tag_body = git_output(&["tag", "-l", "--format=%(contents:body)", tag])
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%b"])
.unwrap_or_default()
});
let semver = match parse_semver_tag(tag) {
Ok(sv) => sv,
Err(e) => {
if skip_validate {
eprintln!("WARNING: current tag is not semver, skipping validation");
SemVer {
major: 0,
minor: 0,
patch: 0,
prerelease: None,
build_metadata: None,
}
} else {
return Err(e);
}
}
};
let first_commit = get_first_commit().ok();
Ok(GitInfo {
tag: tag.to_string(),
commit,
short_commit,
branch,
dirty,
semver,
commit_date,
commit_timestamp,
previous_tag: None,
remote_url,
summary,
tag_subject,
tag_contents,
tag_body,
first_commit,
})
}
const VERSION_PLACEHOLDERS: &[&str] = &[
"{{ .Version }}",
"{{.Version}}",
"{{ Version }}",
"{{Version}}",
];
pub fn has_version_placeholder(template: &str) -> bool {
VERSION_PLACEHOLDERS.iter().any(|p| template.contains(p))
}
pub fn extract_tag_prefix(template: &str) -> Option<String> {
for ph in VERSION_PLACEHOLDERS {
if let Some(idx) = template.find(ph) {
return Some(template[..idx].to_string());
}
}
None
}
pub fn strip_monorepo_prefix<'a>(tag: &'a str, prefix: &str) -> &'a str {
tag.strip_prefix(prefix).unwrap_or(tag)
}
pub fn find_latest_tag_matching(
tag_template: &str,
git_config: Option<&GitConfig>,
template_vars: Option<&TemplateVars>,
) -> Result<Option<String>> {
find_latest_tag_matching_with_prefix(tag_template, git_config, template_vars, None)
}
pub fn find_latest_tag_matching_with_prefix(
tag_template: &str,
git_config: Option<&GitConfig>,
template_vars: Option<&TemplateVars>,
monorepo_prefix: Option<&str>,
) -> Result<Option<String>> {
const SENTINEL: &str = "\x00VERSION_PLACEHOLDER\x00";
let mut tmp = tag_template.to_string();
for placeholder in VERSION_PLACEHOLDERS {
tmp = tmp.replace(placeholder, SENTINEL);
}
let escaped = regex::escape(&tmp);
let pattern = escaped.replace(SENTINEL, r"\d+\.\d+\.\d+(?:-.+)?");
let re = Regex::new(&format!("^{}$", pattern))?;
let (rendered_ignore_tags, rendered_ignore_prefixes) =
render_ignore_patterns(git_config, template_vars);
let ignore_tag_globs: Vec<glob::Pattern> = rendered_ignore_tags
.iter()
.filter_map(|pat| glob::Pattern::new(pat).ok())
.collect();
let tag_sort = git_config
.and_then(|gc| gc.tag_sort.as_deref())
.unwrap_or("-version:refname");
let prerelease_suffix = git_config.and_then(|gc| gc.prerelease_suffix.as_deref());
let use_git_sort = tag_sort == "-version:creatordate" || prerelease_suffix.is_some();
let tags_output = if use_git_sort {
let suffix_cfg;
let mut args: Vec<&str> = Vec::new();
if let Some(suffix) = prerelease_suffix {
suffix_cfg = format!("versionsort.suffix={}", suffix);
args.extend_from_slice(&["-c", &suffix_cfg]);
}
args.extend_from_slice(&["tag", "--sort", tag_sort, "--list"]);
git_output(&args)?
} else {
git_output(&["tag", "--list"])?
};
if tags_output.is_empty() {
return Ok(None);
}
let mut matching: Vec<(SemVer, String)> = tags_output
.lines()
.filter(|t| {
monorepo_prefix
.map(|pfx| t.starts_with(pfx))
.unwrap_or(true)
})
.filter(|t| {
let tag_for_match = monorepo_prefix
.map(|pfx| strip_monorepo_prefix(t, pfx))
.unwrap_or(t);
re.is_match(tag_for_match)
})
.filter(|t| {
let tag_for_ignore = monorepo_prefix
.map(|pfx| strip_monorepo_prefix(t, pfx))
.unwrap_or(t);
!ignore_tag_globs
.iter()
.any(|pat| pat.matches(tag_for_ignore))
})
.filter(|t| {
let tag_for_ignore = monorepo_prefix
.map(|pfx| strip_monorepo_prefix(t, pfx))
.unwrap_or(t);
!rendered_ignore_prefixes
.iter()
.any(|pfx| tag_for_ignore.starts_with(pfx.as_str()))
})
.filter_map(|t| {
let tag_for_parse = monorepo_prefix
.map(|pfx| strip_monorepo_prefix(t, pfx))
.unwrap_or(t);
parse_semver_tag(tag_for_parse)
.ok()
.map(|v| (v, t.to_string()))
})
.collect();
if use_git_sort {
Ok(matching.into_iter().next().map(|(_, tag)| tag))
} else {
matching.sort_by(|a, b| a.0.cmp(&b.0));
Ok(matching.last().map(|(_, tag)| tag.clone()))
}
}
fn parse_commit_output(output: &str) -> Vec<Commit> {
if output.is_empty() {
return vec![];
}
output
.split('\x1e')
.filter(|record| !record.trim().is_empty())
.filter_map(|record| {
let fields: Vec<&str> = record.split('\x1f').collect();
if fields.len() >= 5 {
Some(Commit {
hash: fields[0].trim().to_string(),
short_hash: fields[1].to_string(),
message: fields[2].to_string(),
author_name: fields[3].to_string(),
author_email: fields[4].to_string(),
body: fields.get(5).unwrap_or(&"").trim().to_string(),
})
} else {
None
}
})
.collect()
}
pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
get_commits_between_paths(
from,
to,
&path_filter
.into_iter()
.map(String::from)
.collect::<Vec<_>>(),
)
}
pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
let range = format!("{}..{}", from, to);
let mut args = vec![
"-c".to_string(),
"log.showSignature=false".to_string(),
"log".to_string(),
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
range,
];
if !paths.is_empty() {
args.push("--".to_string());
for p in paths {
args.push(p.clone());
}
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = git_output(&arg_refs)?;
Ok(parse_commit_output(&output))
}
pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
get_all_commits_paths(
&path_filter
.into_iter()
.map(String::from)
.collect::<Vec<_>>(),
)
}
pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
let mut args = vec![
"-c".to_string(),
"log.showSignature=false".to_string(),
"log".to_string(),
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
"HEAD".to_string(),
];
if !paths.is_empty() {
args.push("--".to_string());
for p in paths {
args.push(p.clone());
}
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = git_output(&arg_refs)?;
Ok(parse_commit_output(&output))
}
fn collect_semver_tags(
git_args: &[&str],
prefix: &str,
git_config: Option<&GitConfig>,
template_vars: Option<&TemplateVars>,
) -> Result<Vec<String>> {
let tags_output = git_output(git_args)?;
if tags_output.is_empty() {
return Ok(vec![]);
}
let (rendered_ignore_tags, rendered_ignore_prefixes) =
render_ignore_patterns(git_config, template_vars);
let ignore_tag_globs: Vec<glob::Pattern> = rendered_ignore_tags
.iter()
.filter_map(|pat| glob::Pattern::new(pat).ok())
.collect();
let mut matching: Vec<(SemVer, String)> = tags_output
.lines()
.filter(|t| t.starts_with(prefix))
.filter(|t| !ignore_tag_globs.iter().any(|g| g.matches(t)))
.filter(|t| {
!rendered_ignore_prefixes
.iter()
.any(|p| !p.is_empty() && t.starts_with(p))
})
.filter_map(|t| parse_semver_tag(t).ok().map(|v| (v, t.to_string())))
.collect();
matching.sort_by(|a, b| b.0.cmp(&a.0));
Ok(matching.into_iter().map(|(_, tag)| tag).collect())
}
pub fn get_all_semver_tags(
prefix: &str,
git_config: Option<&GitConfig>,
template_vars: Option<&TemplateVars>,
) -> Result<Vec<String>> {
collect_semver_tags(&["tag", "--list"], prefix, git_config, template_vars)
}
pub fn get_branch_semver_tags(
prefix: &str,
git_config: Option<&GitConfig>,
template_vars: Option<&TemplateVars>,
) -> Result<Vec<String>> {
collect_semver_tags(
&["tag", "--merged", "HEAD", "--list"],
prefix,
git_config,
template_vars,
)
}
pub fn create_and_push_tag(
tag: &str,
message: &str,
dry_run: bool,
log: &crate::log::StageLogger,
strict: bool,
) -> Result<()> {
if dry_run {
log.status(&format!(
"(dry-run) would create tag: {} (\"{}\")",
tag, message
));
return Ok(());
}
git_output(&["tag", "-a", tag, "-m", message])?;
let has_remote = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if has_remote {
git_output(&["push", "origin", tag])?;
} else if strict {
anyhow::bail!("no 'origin' remote found, cannot push tag (strict mode)");
} else {
log.warn("no 'origin' remote found, skipping push");
}
Ok(())
}
pub fn gh_api_get(endpoint: &str, token: Option<&str>) -> Result<serde_json::Value> {
let mut cmd = Command::new("gh");
cmd.args(["api", endpoint]);
if let Some(tok) = token {
cmd.env("GITHUB_TOKEN", tok);
}
let output = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.context("failed to spawn gh CLI")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh api GET {} failed: {}", endpoint, stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).context("failed to parse gh api response")
}
pub fn gh_api_get_paginated(endpoint: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
let mut cmd = Command::new("gh");
cmd.args(["api", "--paginate", endpoint]);
if let Some(tok) = token {
cmd.env("GITHUB_TOKEN", tok);
}
let output = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.context("failed to spawn gh CLI")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh api GET {} failed: {}", endpoint, stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&stdout) {
return Ok(arr);
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
return Ok(vec![val]);
}
let mut all_items = Vec::new();
for chunk in stdout.split_inclusive(']') {
let trimmed = chunk.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(serde_json::Value::Array(arr)) =
serde_json::from_str::<serde_json::Value>(trimmed)
{
all_items.extend(arr);
} else if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
all_items.push(val);
} else {
eprintln!(
"warning: gh_api_get_paginated: failed to parse JSON chunk (len={}): {:?}",
trimmed.len(),
&trimmed[..trimmed.len().min(200)]
);
}
}
Ok(all_items)
}
fn gh_api_post(endpoint: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
use std::io::Write;
let body_str = serde_json::to_string(body)?;
let mut child = Command::new("gh")
.args(["api", "--method", "POST", endpoint, "--input", "-"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to spawn gh CLI")?;
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(body_str.as_bytes())?;
}
child.stdin.take();
let output = child.wait_with_output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh api POST {} failed: {}", endpoint, stderr.trim());
}
let response: serde_json::Value = serde_json::from_slice(&output.stdout)
.with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
Ok(response)
}
pub fn create_tag_via_github_api(
tag: &str,
message: &str,
dry_run: bool,
log: &crate::log::StageLogger,
strict: bool,
) -> Result<()> {
if dry_run {
log.status(&format!(
"(dry-run) would create tag via GitHub API: {} (\"{}\")",
tag, message
));
return Ok(());
}
let (owner, repo) = detect_github_repo()?;
let sha = git_output(&["rev-parse", "HEAD"])?;
let body = serde_json::json!({
"tag": tag,
"message": message,
"object": sha,
"type": "commit",
"tagger": {
"name": git_output(&["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
"email": git_output(&["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
"date": chrono::Utc::now().to_rfc3339(),
}
});
let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
let response = match gh_api_post(&tag_endpoint, &body) {
Ok(resp) => resp,
Err(e) => {
if e.to_string().contains("failed to spawn gh CLI") {
if strict {
anyhow::bail!(
"gh CLI not found, cannot create tag via GitHub API (strict mode)"
);
}
log.warn("gh CLI not found, falling back to local git tag + push");
return create_and_push_tag(tag, message, dry_run, log, strict);
}
return Err(e);
}
};
let tag_sha = response["sha"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
let ref_body = serde_json::json!({
"ref": format!("refs/tags/{}", tag),
"sha": tag_sha,
});
let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
gh_api_post(&ref_endpoint, &ref_body)?;
Ok(())
}
pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
let output = git_output(&[
"-c",
"log.showSignature=false",
"log",
&format!("-{count}"),
"--pretty=format:%s",
])?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
let output = git_output(&[
"-c",
"log.showSignature=false",
"log",
"--pretty=format:%s",
&format!("{from}..{to}"),
])?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn get_current_branch() -> Result<String> {
git_output(&["rev-parse", "--abbrev-ref", "HEAD"])
}
pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
let range = format!("{}..HEAD", tag);
let output = git_output(&["-c", "log.showSignature=false", "log", "--oneline", &range])?;
Ok(!output.is_empty())
}
pub fn get_short_commit() -> Result<String> {
git_output(&["rev-parse", "--short", "HEAD"])
}
pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
let output = git_output(&["diff", "--name-only", &format!("{}..HEAD", tag), "--", path])?;
Ok(!output.is_empty())
}
pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
let output = git_output(&[
"-c",
"log.showSignature=false",
"log",
&format!("-{count}"),
"--pretty=format:%s",
"--",
path,
])?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
let output = git_output(&[
"-c",
"log.showSignature=false",
"log",
"--pretty=format:%s",
&format!("{from}..{to}"),
"--",
path,
])?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn stage_and_commit(files: &[&str], message: &str) -> Result<()> {
let mut args = vec!["add", "--"];
args.extend(files.iter().copied());
git_output(&args)?;
git_output(&["commit", "-m", message])?;
Ok(())
}
pub fn parse_github_remote(url: &str) -> Option<(String, String)> {
let url = url.trim();
if url.is_empty() {
return None;
}
let url = url.strip_suffix(".git").unwrap_or(url);
if let Some(path) = url.strip_prefix("https://github.com/") {
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
return Some((parts[0].to_string(), parts[1].to_string()));
}
}
if let Some(path) = url.strip_prefix("git@github.com:") {
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
return Some((parts[0].to_string(), parts[1].to_string()));
}
}
None
}
pub fn detect_github_repo() -> Result<(String, String)> {
let url = git_output(&["remote", "get-url", "origin"])?;
parse_github_remote(&url).ok_or_else(|| {
anyhow::anyhow!("could not parse GitHub owner/repo from remote URL: {}", url)
})
}
pub fn parse_remote_owner_repo(url: &str) -> Option<(String, String)> {
let url = url.trim();
if url.is_empty() {
return None;
}
let url = url.strip_suffix(".git").unwrap_or(url);
if url.starts_with("https://") || url.starts_with("http://") {
let after_scheme = if let Some(rest) = url.strip_prefix("https://") {
rest
} else {
url.strip_prefix("http://")?
};
let after_host = after_scheme.find('/').map(|i| &after_scheme[i + 1..])?;
let last_slash = after_host.rfind('/')?;
let owner = &after_host[..last_slash];
let repo = &after_host[last_slash + 1..];
if !owner.is_empty() && !repo.is_empty() {
return Some((owner.to_string(), repo.to_string()));
}
}
if let Some(colon_pos) = url.find(':') {
let before_colon = &url[..colon_pos];
if before_colon.contains('@') && !before_colon.contains("//") {
let path = &url[colon_pos + 1..];
let last_slash = path.rfind('/')?;
let owner = &path[..last_slash];
let repo = &path[last_slash + 1..];
if !owner.is_empty() && !repo.is_empty() {
return Some((owner.to_string(), repo.to_string()));
}
}
}
None
}
pub fn detect_owner_repo() -> Result<(String, String)> {
let url = git_output(&["remote", "get-url", "origin"])?;
parse_remote_owner_repo(&url)
.ok_or_else(|| anyhow::anyhow!("could not parse owner/repo from remote URL: {}", url))
}
pub fn find_previous_tag(
current_tag: &str,
git_config: Option<&GitConfig>,
template_vars: Option<&TemplateVars>,
) -> Result<Option<String>> {
find_previous_tag_with_prefix(current_tag, git_config, template_vars, None)
}
pub fn find_previous_tag_with_prefix(
current_tag: &str,
git_config: Option<&GitConfig>,
template_vars: Option<&TemplateVars>,
monorepo_prefix: Option<&str>,
) -> Result<Option<String>> {
let parent_ref = format!("{}^", current_tag);
let (rendered_ignore_tags, rendered_ignore_prefixes) =
render_ignore_patterns(git_config, template_vars);
let mut exclude_args: Vec<String> = rendered_ignore_tags
.iter()
.map(|t| format!("--exclude={}", t))
.collect();
for pfx in &rendered_ignore_prefixes {
exclude_args.push(format!("--exclude={}*", pfx));
}
let match_arg;
let mut args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
if let Some(prefix) = monorepo_prefix {
match_arg = format!("--match={}*", prefix);
args.push(&match_arg);
}
for ea in &exclude_args {
args.push(ea.as_str());
}
args.push(&parent_ref);
match git_output(&args) {
Ok(tag) if !tag.is_empty() => Ok(Some(tag)),
_ => Ok(None),
}
}
pub fn get_first_commit() -> Result<String> {
let output = git_output(&["rev-list", "--max-parents=0", "HEAD"])?;
output
.lines()
.last()
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("no commits found in repository"))
}
pub fn tag_points_at_head(tag: &str) -> Result<bool> {
let deref = format!("{}^{{}}", tag);
let tag_sha = git_output(&["rev-parse", &deref])?;
let head_sha = git_output(&["rev-parse", "HEAD"])?;
Ok(tag_sha == head_sha)
}
pub fn check_git_available() -> Result<()> {
let output = Command::new("git").arg("--version").output();
match output {
Ok(o) if o.status.success() => Ok(()),
_ => bail!("git is not installed or not in PATH. Install git and try again."),
}
}
pub fn is_git_repo() -> bool {
git_output(&["rev-parse", "--git-dir"]).is_ok()
}
pub fn git_status_porcelain() -> String {
git_output(&["status", "--porcelain"]).unwrap_or_default()
}
pub fn is_shallow_clone() -> bool {
let git_dir = git_output(&["rev-parse", "--git-dir"]).unwrap_or_else(|_| ".git".to_string());
std::path::Path::new(&git_dir).join("shallow").exists()
}
#[cfg(test)]
mod tests {
use super::*;
#[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_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_strip_url_credentials_with_userinfo() {
assert_eq!(
strip_url_credentials("https://user:token@github.com/owner/repo.git"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_no_userinfo() {
assert_eq!(
strip_url_credentials("https://github.com/owner/repo.git"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_ssh_unchanged() {
assert_eq!(
strip_url_credentials("git@github.com:owner/repo.git"),
"git@github.com:owner/repo.git"
);
}
#[test]
fn test_strip_url_credentials_user_only() {
assert_eq!(
strip_url_credentials("https://user@github.com/owner/repo.git"),
"https://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);
}
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 = Command::new("git")
.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")
.output()
.unwrap();
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]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(dir).unwrap();
let result = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
assert_eq!(result, Some("v2.0.0".to_string()));
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()]);
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()]);
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()]);
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()));
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()));
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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);
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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);
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()));
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()));
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()));
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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 = std::process::Command::new("git")
.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")
.output()
.unwrap();
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"
);
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()));
std::env::set_current_dir(orig).unwrap();
}
fn init_repo_with_tagged_commits(dir: &std::path::Path, tags: &[&str]) {
use std::process::Command;
let run = |args: &[&str]| {
let out = Command::new("git")
.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")
.output()
.unwrap();
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]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()));
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()));
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(dir).unwrap();
let result = find_previous_tag("v2.0.0", None, None).unwrap();
assert_eq!(result, Some("v1.0.0".to_string()));
std::env::set_current_dir(orig).unwrap();
}
#[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]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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"
);
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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"
);
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(dir).unwrap();
let result =
find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("myapp/"))
.unwrap();
assert_eq!(result, None);
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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()));
std::env::set_current_dir(orig).unwrap();
}
#[test]
#[serial]
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 orig = std::env::current_dir().unwrap();
std::env::set_current_dir(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"
);
std::env::set_current_dir(orig).unwrap();
}
}