Skip to main content

anodizer_core/
git.rs

1use anyhow::{Context as _, Result, bail};
2use regex::Regex;
3use std::process::Command;
4use std::sync::LazyLock;
5
6use crate::config::GitConfig;
7use crate::template::TemplateVars;
8
9/// Render ignore patterns (both `ignore_tags` and `ignore_tag_prefixes`) through
10/// the template engine when `template_vars` is provided.
11///
12/// Returns two vecs: `(rendered_ignore_tags, rendered_ignore_tag_prefixes)`.
13/// When `vars` is `None`, patterns are returned as-is (unrendered).
14pub fn render_ignore_patterns(
15    git_config: Option<&GitConfig>,
16    vars: Option<&TemplateVars>,
17) -> (Vec<String>, Vec<String>) {
18    let rendered_tags: Vec<String> = git_config
19        .and_then(|gc| gc.ignore_tags.as_ref())
20        .map(|v| {
21            v.iter()
22                .map(|s| {
23                    if let Some(tv) = vars {
24                        crate::template::render(s, tv).unwrap_or_else(|_| s.clone())
25                    } else {
26                        s.clone()
27                    }
28                })
29                .collect()
30        })
31        .unwrap_or_default();
32    let rendered_prefixes: Vec<String> = git_config
33        .and_then(|gc| gc.ignore_tag_prefixes.as_ref())
34        .map(|v| {
35            v.iter()
36                .map(|s| {
37                    if let Some(tv) = vars {
38                        crate::template::render(s, tv).unwrap_or_else(|_| s.clone())
39                    } else {
40                        s.clone()
41                    }
42                })
43                .collect()
44        })
45        .unwrap_or_default();
46    (rendered_tags, rendered_prefixes)
47}
48
49#[derive(Debug, Clone)]
50pub struct SemVer {
51    pub major: u64,
52    pub minor: u64,
53    pub patch: u64,
54    pub prerelease: Option<String>,
55    pub build_metadata: Option<String>,
56}
57
58impl SemVer {
59    pub fn is_prerelease(&self) -> bool {
60        self.prerelease.is_some()
61    }
62}
63
64impl PartialEq for SemVer {
65    fn eq(&self, other: &Self) -> bool {
66        self.major == other.major
67            && self.minor == other.minor
68            && self.patch == other.patch
69            && self.prerelease == other.prerelease
70    }
71}
72
73impl Eq for SemVer {}
74
75impl PartialOrd for SemVer {
76    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
77        Some(self.cmp(other))
78    }
79}
80
81impl Ord for SemVer {
82    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
83        self.major
84            .cmp(&other.major)
85            .then(self.minor.cmp(&other.minor))
86            .then(self.patch.cmp(&other.patch))
87            .then(match (&self.prerelease, &other.prerelease) {
88                (Some(_), None) => std::cmp::Ordering::Less, // prerelease < release
89                (None, Some(_)) => std::cmp::Ordering::Greater, // release > prerelease
90                (Some(a), Some(b)) => compare_prerelease(a, b),
91                (None, None) => std::cmp::Ordering::Equal,
92            })
93    }
94}
95
96/// Compare two prerelease strings per SemVer 2.0.0 section 11.
97///
98/// Dot-separated identifiers are compared individually: numeric identifiers are
99/// compared as integers, alphanumeric identifiers are compared lexicographically,
100/// and numeric identifiers always have lower precedence than alphanumeric ones.
101/// A shorter set of identifiers has lower precedence when all preceding
102/// identifiers are equal.
103fn compare_prerelease(a: &str, b: &str) -> std::cmp::Ordering {
104    use std::cmp::Ordering;
105
106    let a_ids: Vec<&str> = a.split('.').collect();
107    let b_ids: Vec<&str> = b.split('.').collect();
108
109    for (ai, bi) in a_ids.iter().zip(b_ids.iter()) {
110        let ord = match (ai.parse::<u64>(), bi.parse::<u64>()) {
111            (Ok(an), Ok(bn)) => an.cmp(&bn), // both numeric: compare as integers
112            (Ok(_), Err(_)) => Ordering::Less, // numeric < alphanumeric
113            (Err(_), Ok(_)) => Ordering::Greater, // alphanumeric > numeric
114            (Err(_), Err(_)) => ai.cmp(bi),  // both alpha: lexicographic
115        };
116        if ord != Ordering::Equal {
117            return ord;
118        }
119    }
120    // Shorter set has lower precedence
121    a_ids.len().cmp(&b_ids.len())
122}
123
124/// Compiled once and reused across all calls to [`parse_semver`].
125///
126/// Captures: 1=major, 2=minor, 3=patch, 4=prerelease (optional), 5=build metadata (optional).
127/// Prerelease is after `-` but before `+`. Build metadata is after `+`.
128static SEMVER_RE: LazyLock<Regex> =
129    LazyLock::new(|| crate::util::static_regex(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(.+))?$"));
130
131/// Parse a strict semver version from a string like "v1.2.3", "1.2.3", "v1.0.0-rc.1",
132/// "v1.0.0+build.42", or "v1.0.0-rc.1+build.42".
133///
134/// The string must start with an optional `v` prefix followed by the version.
135/// For prefixed tags like "cfgd-core-v2.1.0", use [`parse_semver_tag`] instead.
136pub fn parse_semver(tag: &str) -> Result<SemVer> {
137    let caps = SEMVER_RE
138        .captures(tag)
139        .ok_or_else(|| anyhow::anyhow!("not a valid semver tag: {}", tag))?;
140    Ok(SemVer {
141        major: caps[1].parse()?,
142        minor: caps[2].parse()?,
143        patch: caps[3].parse()?,
144        prerelease: caps.get(4).map(|m| m.as_str().to_string()),
145        build_metadata: caps.get(5).map(|m| m.as_str().to_string()),
146    })
147}
148
149/// Parse a semver version from a prefixed tag string.
150///
151/// Strips everything up to and including the last `-` or `_` before the version
152/// portion, then delegates to [`parse_semver`]. Handles tags like
153/// "cfgd-core-v2.1.0", "my_project-v1.0.0-rc.1", or plain "v1.2.3".
154pub fn parse_semver_tag(tag: &str) -> Result<SemVer> {
155    // Try strict parse first (handles "v1.2.3" and "1.2.3")
156    if let Ok(sv) = parse_semver(tag) {
157        return Ok(sv);
158    }
159    // Find the version portion: look for `v?\d+.\d+.\d+` after a separator
160    static PREFIX_RE: LazyLock<Regex> =
161        LazyLock::new(|| crate::util::static_regex(r"[-_/](v?\d+\.\d+\.\d+(?:-[^+]+)?(?:\+.+)?)$"));
162    if let Some(caps) = PREFIX_RE.captures(tag) {
163        return parse_semver(&caps[1]);
164    }
165    anyhow::bail!("not a valid semver tag: {}", tag)
166}
167
168#[derive(Debug, Clone)]
169pub struct GitInfo {
170    pub tag: String,
171    pub commit: String,
172    pub short_commit: String,
173    pub branch: String,
174    pub dirty: bool,
175    pub semver: SemVer,
176    /// ISO 8601 committer date of HEAD commit (from `git log -1 --format=%cI`)
177    pub commit_date: String,
178    /// Unix timestamp of HEAD commit (from `git log -1 --format=%at`)
179    pub commit_timestamp: String,
180    /// Previous tag matching the same pattern, if any.
181    /// Populated externally by the release command once the tag_template is known.
182    pub previous_tag: Option<String>,
183    /// Remote URL from `git remote get-url origin`.
184    pub remote_url: String,
185    /// Git describe summary (e.g. `v1.0.0-10-g34f56g3`) from `git describe --tags --always`.
186    pub summary: String,
187    /// Annotated tag subject (first line of tag message) or commit subject.
188    pub tag_subject: String,
189    /// Full annotated tag message or full commit message.
190    pub tag_contents: String,
191    /// Tag message body (everything after first line) or commit message body.
192    pub tag_body: String,
193    /// First commit hash in the repository (for changelog range when no previous tag).
194    pub first_commit: Option<String>,
195}
196
197#[derive(Debug, Clone)]
198pub struct Commit {
199    pub hash: String,
200    pub short_hash: String,
201    pub message: String,
202    pub author_name: String,
203    pub author_email: String,
204    /// Full commit message body (everything after the subject line).
205    /// Contains trailers like `Co-Authored-By:`.
206    pub body: String,
207}
208
209/// Run a git command and return stdout, trimmed.
210fn git_output(args: &[&str]) -> Result<String> {
211    let output = Command::new("git").args(args).output()?;
212    if !output.status.success() {
213        let stderr = String::from_utf8_lossy(&output.stderr);
214        bail!("git {} failed: {}", args.join(" "), stderr.trim());
215    }
216    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
217}
218
219/// Check whether the working tree has uncommitted changes.
220pub fn is_git_dirty() -> bool {
221    git_output(&["status", "--porcelain"])
222        .map(|s| !s.is_empty())
223        .unwrap_or(false)
224}
225
226/// Read `git config user.name`, or `None` if unset / git is unavailable.
227pub fn local_git_user_name() -> Option<String> {
228    git_output(&["config", "user.name"])
229        .ok()
230        .filter(|s| !s.is_empty())
231}
232
233/// Read `git config user.email`, or `None` if unset / git is unavailable.
234pub fn local_git_user_email() -> Option<String> {
235    git_output(&["config", "user.email"])
236        .ok()
237        .filter(|s| !s.is_empty())
238}
239
240/// Strip userinfo (credentials) from an HTTPS URL.
241///
242/// If the URL starts with `https://` and contains `@`, everything between
243/// `://` and `@` is removed (e.g. `https://user:token@github.com/...` becomes
244/// `https://github.com/...`). Non-HTTPS URLs are returned unchanged.
245fn strip_url_credentials(url: &str) -> String {
246    if let Some(rest) = url.strip_prefix("https://")
247        && let Some(at_pos) = rest.find('@')
248    {
249        return format!("https://{}", &rest[at_pos + 1..]);
250    }
251    url.to_string()
252}
253
254/// Detect git info for a given tag.
255///
256/// When `skip_validate` is true and the tag is not valid semver, a warning is
257/// logged and a default `SemVer { 0, 0, 0 }` is used instead of returning an error.
258///
259/// When `snapshot` is true and the working directory is not inside a git
260/// repository, a synthetic `GitInfo` is returned (commit/branch/etc. left
261/// empty) so users can run `anodizer release --snapshot` from a fresh tarball
262/// or scratch directory without git ever having been initialized. Outside
263/// snapshot mode, the missing repo bubbles as an error.
264pub fn detect_git_info(tag: &str, skip_validate: bool) -> Result<GitInfo> {
265    if !is_git_repo() {
266        // Synthetic GitInfo for non-repo snapshot/scratch builds. Lets users
267        // run `anodizer release --snapshot` from a fresh tarball or scratch
268        // directory without `git init` first. Caller is responsible for only
269        // accepting this in snapshot/dry-run mode.
270        return Ok(GitInfo {
271            tag: tag.to_string(),
272            commit: String::new(),
273            short_commit: String::new(),
274            branch: String::new(),
275            dirty: false,
276            semver: SemVer {
277                major: 0,
278                minor: 0,
279                patch: 0,
280                prerelease: None,
281                build_metadata: None,
282            },
283            commit_date: String::new(),
284            commit_timestamp: String::new(),
285            previous_tag: None,
286            remote_url: String::new(),
287            summary: String::new(),
288            tag_subject: String::new(),
289            tag_contents: String::new(),
290            tag_body: String::new(),
291            first_commit: None,
292        });
293    }
294    let commit = git_output(&["rev-parse", "HEAD"])?;
295    let short_commit = git_output(&["rev-parse", "--short", "HEAD"])?;
296    let branch = git_output(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default();
297    let dirty = is_git_dirty();
298    let commit_date = git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%cI"])
299        .unwrap_or_default();
300    let commit_timestamp =
301        git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%at"])
302            .unwrap_or_default();
303    // Use ls-remote --get-url (matches GoReleaser git.go:355).
304    // Without an explicit remote name this defaults to "origin".
305    let remote_url_raw = git_output(&["ls-remote", "--get-url"]).unwrap_or_default();
306    // Strip credentials from HTTPS URLs (e.g. https://user:token@github.com/... → https://github.com/...)
307    let remote_url = strip_url_credentials(&remote_url_raw);
308    let summary = git_output(&[
309        "-c",
310        "log.showSignature=false",
311        "describe",
312        "--tags",
313        "--always",
314        "--dirty",
315    ])
316    .unwrap_or_default();
317
318    // Try annotated tag message fields first; fall back to commit message fields.
319    let tag_subject = git_output(&["tag", "-l", "--format=%(contents:subject)", tag])
320        .ok()
321        .filter(|s| !s.is_empty())
322        .unwrap_or_else(|| {
323            git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%s"])
324                .unwrap_or_default()
325        });
326    let tag_contents = git_output(&["tag", "-l", "--format=%(contents)", tag])
327        .ok()
328        .filter(|s| !s.is_empty())
329        .unwrap_or_else(|| {
330            git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%B"])
331                .unwrap_or_default()
332        });
333    let tag_body = git_output(&["tag", "-l", "--format=%(contents:body)", tag])
334        .ok()
335        .filter(|s| !s.is_empty())
336        .unwrap_or_else(|| {
337            git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%b"])
338                .unwrap_or_default()
339        });
340
341    let semver = match parse_semver_tag(tag) {
342        Ok(sv) => sv,
343        Err(e) => {
344            if skip_validate {
345                eprintln!("WARNING: current tag is not semver, skipping validation");
346                SemVer {
347                    major: 0,
348                    minor: 0,
349                    patch: 0,
350                    prerelease: None,
351                    build_metadata: None,
352                }
353            } else {
354                return Err(e);
355            }
356        }
357    };
358    let first_commit = get_first_commit().ok();
359    Ok(GitInfo {
360        tag: tag.to_string(),
361        commit,
362        short_commit,
363        branch,
364        dirty,
365        semver,
366        commit_date,
367        commit_timestamp,
368        previous_tag: None,
369        remote_url,
370        summary,
371        tag_subject,
372        tag_contents,
373        tag_body,
374        first_commit,
375    })
376}
377
378/// The four accepted placeholder forms for the version variable in tag templates.
379const VERSION_PLACEHOLDERS: &[&str] = &[
380    "{{ .Version }}",
381    "{{.Version}}",
382    "{{ Version }}",
383    "{{Version}}",
384];
385
386/// Check whether a tag template string contains any recognised version placeholder.
387pub fn has_version_placeholder(template: &str) -> bool {
388    VERSION_PLACEHOLDERS.iter().any(|p| template.contains(p))
389}
390
391/// Extract the prefix portion of a tag template by locating the version placeholder.
392///
393/// Returns the substring before the first recognised placeholder, or `None` if no
394/// placeholder is found.
395pub fn extract_tag_prefix(template: &str) -> Option<String> {
396    for ph in VERSION_PLACEHOLDERS {
397        if let Some(idx) = template.find(ph) {
398            return Some(template[..idx].to_string());
399        }
400    }
401    None
402}
403
404/// Strip a monorepo tag prefix from a tag string.
405///
406/// If `tag` starts with `prefix`, returns the remainder; otherwise returns
407/// the original tag unchanged.
408///
409/// # Examples
410/// ```
411/// # use anodizer_core::git::strip_monorepo_prefix;
412/// assert_eq!(strip_monorepo_prefix("subproject1/v1.2.3", "subproject1/"), "v1.2.3");
413/// assert_eq!(strip_monorepo_prefix("v1.2.3", "subproject1/"), "v1.2.3");
414/// ```
415pub fn strip_monorepo_prefix<'a>(tag: &'a str, prefix: &str) -> &'a str {
416    tag.strip_prefix(prefix).unwrap_or(tag)
417}
418
419/// Find the latest tag matching a template pattern.
420/// E.g., tag_template "cfgd-core-v{{ .Version }}" → matches tags like "cfgd-core-v1.2.3"
421///
422/// When `git_config` is provided:
423/// - `ignore_tags`: tags matching any entry (glob patterns) are excluded.
424///   When `template_vars` is also provided, each entry is rendered through the
425///   template engine first (matching GoReleaser's behavior).
426/// - `ignore_tag_prefixes`: tags starting with any prefix are excluded.
427///   Also template-rendered when `template_vars` is provided.
428/// - `tag_sort` set to `"-version:creatordate"`: delegates ordering to git
429///   instead of Rust-side SemVer sort (the default `"-version:refname"` is
430///   equivalent to SemVer sort, so Rust-side sort is kept).
431/// - `prerelease_suffix`: always passed as `-c versionsort.suffix=<suffix>` to
432///   git, regardless of `tag_sort` value. When using the default refname sort
433///   and `prerelease_suffix` is set, git-delegated sort with
434///   `--sort=-version:refname` is used so the suffix takes effect.
435pub fn find_latest_tag_matching(
436    tag_template: &str,
437    git_config: Option<&GitConfig>,
438    template_vars: Option<&TemplateVars>,
439) -> Result<Option<String>> {
440    find_latest_tag_matching_with_prefix(tag_template, git_config, template_vars, None)
441}
442
443/// Like [`find_latest_tag_matching`], but with optional monorepo prefix filtering.
444///
445/// When `monorepo_prefix` is `Some`:
446/// - Only tags starting with the prefix are considered.
447/// - The prefix is stripped before SemVer parsing (so `subproject1/v1.2.3`
448///   parses as `v1.2.3` for version comparison).
449/// - The FULL tag (with prefix) is returned as the result.
450pub fn find_latest_tag_matching_with_prefix(
451    tag_template: &str,
452    git_config: Option<&GitConfig>,
453    template_vars: Option<&TemplateVars>,
454    monorepo_prefix: Option<&str>,
455) -> Result<Option<String>> {
456    // Replace version placeholders with a sentinel, regex-escape everything
457    // else, then swap the sentinel back to the version regex pattern.
458    // This prevents regex metacharacters in the prefix (e.g. dots in
459    // project names) from being interpreted as regex operators.
460    const SENTINEL: &str = "\x00VERSION_PLACEHOLDER\x00";
461    let mut tmp = tag_template.to_string();
462    for placeholder in VERSION_PLACEHOLDERS {
463        tmp = tmp.replace(placeholder, SENTINEL);
464    }
465    let escaped = regex::escape(&tmp);
466    let pattern = escaped.replace(SENTINEL, r"\d+\.\d+\.\d+(?:-.+)?");
467    let re = Regex::new(&format!("^{}$", pattern))?;
468
469    // Use the shared helper to render ignore_tags and ignore_tag_prefixes
470    // through the template engine when vars are available.
471    let (rendered_ignore_tags, rendered_ignore_prefixes) =
472        render_ignore_patterns(git_config, template_vars);
473
474    // Compile ignore_tags entries as glob patterns for consistent behavior
475    // with `find_previous_tag` (which passes them to `git describe --exclude`
476    // which interprets globs). This matches GoReleaser's behavior.
477    let ignore_tag_globs: Vec<glob::Pattern> = rendered_ignore_tags
478        .iter()
479        .filter_map(|pat| glob::Pattern::new(pat).ok())
480        .collect();
481
482    let tag_sort = git_config
483        .and_then(|gc| gc.tag_sort.as_deref())
484        .unwrap_or("-version:refname");
485    let prerelease_suffix = git_config.and_then(|gc| gc.prerelease_suffix.as_deref());
486
487    // When prerelease_suffix is set, always use git-delegated sort so that
488    // `-c versionsort.suffix=<suffix>` takes effect. This matches GoReleaser's
489    // behavior of always passing the suffix regardless of sort mode.
490    let use_git_sort = tag_sort == "-version:creatordate" || prerelease_suffix.is_some();
491
492    let tags_output = if use_git_sort {
493        // Build args with optional versionsort.suffix config.
494        let suffix_cfg;
495        let mut args: Vec<&str> = Vec::new();
496        if let Some(suffix) = prerelease_suffix {
497            suffix_cfg = format!("versionsort.suffix={}", suffix);
498            args.extend_from_slice(&["-c", &suffix_cfg]);
499        }
500        args.extend_from_slice(&["tag", "--sort", tag_sort, "--list"]);
501        git_output(&args)?
502    } else {
503        git_output(&["tag", "--list"])?
504    };
505
506    if tags_output.is_empty() {
507        return Ok(None);
508    }
509
510    let mut matching: Vec<(SemVer, String)> = tags_output
511        .lines()
512        // When monorepo_prefix is set, only consider tags starting with it.
513        .filter(|t| {
514            monorepo_prefix
515                .map(|pfx| t.starts_with(pfx))
516                .unwrap_or(true)
517        })
518        // For regex matching: when monorepo_prefix is set, strip the prefix
519        // before matching (the tag_template pattern matches the version portion).
520        .filter(|t| {
521            let tag_for_match = monorepo_prefix
522                .map(|pfx| strip_monorepo_prefix(t, pfx))
523                .unwrap_or(t);
524            re.is_match(tag_for_match)
525        })
526        // Apply ignore_tags: exclude via glob matching (template-rendered).
527        // In monorepo mode, match against the STRIPPED tag so that user-defined
528        // patterns like "v*-rc*" work without needing the monorepo prefix.
529        .filter(|t| {
530            let tag_for_ignore = monorepo_prefix
531                .map(|pfx| strip_monorepo_prefix(t, pfx))
532                .unwrap_or(t);
533            !ignore_tag_globs
534                .iter()
535                .any(|pat| pat.matches(tag_for_ignore))
536        })
537        // Apply ignore_tag_prefixes: exclude tags starting with any prefix
538        // (template-rendered). In monorepo mode, match against stripped tag.
539        .filter(|t| {
540            let tag_for_ignore = monorepo_prefix
541                .map(|pfx| strip_monorepo_prefix(t, pfx))
542                .unwrap_or(t);
543            !rendered_ignore_prefixes
544                .iter()
545                .any(|pfx| tag_for_ignore.starts_with(pfx.as_str()))
546        })
547        // For SemVer parsing: strip the monorepo prefix before parsing.
548        .filter_map(|t| {
549            let tag_for_parse = monorepo_prefix
550                .map(|pfx| strip_monorepo_prefix(t, pfx))
551                .unwrap_or(t);
552            parse_semver_tag(tag_for_parse)
553                .ok()
554                .map(|v| (v, t.to_string()))
555        })
556        .collect();
557
558    if use_git_sort {
559        // Git already sorted; the first entry in --sort=-version:* output is
560        // the newest, so take the first after filtering.
561        Ok(matching.into_iter().next().map(|(_, tag)| tag))
562    } else {
563        // Rust-side SemVer sort (ascending), pick the last (highest).
564        matching.sort_by(|a, b| a.0.cmp(&b.0));
565        Ok(matching.last().map(|(_, tag)| tag.clone()))
566    }
567}
568
569/// Parse git log output (formatted as `%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e`)
570/// into a vec of [`Commit`]s.
571///
572/// Uses ASCII record separator (0x1e) between commits and unit separator (0x1f)
573/// between fields, so multi-line body text doesn't break parsing.
574fn parse_commit_output(output: &str) -> Vec<Commit> {
575    if output.is_empty() {
576        return vec![];
577    }
578    output
579        .split('\x1e')
580        .filter(|record| !record.trim().is_empty())
581        .filter_map(|record| {
582            let fields: Vec<&str> = record.split('\x1f').collect();
583            if fields.len() >= 5 {
584                Some(Commit {
585                    hash: fields[0].trim().to_string(),
586                    short_hash: fields[1].to_string(),
587                    message: fields[2].to_string(),
588                    author_name: fields[3].to_string(),
589                    author_email: fields[4].to_string(),
590                    body: fields.get(5).unwrap_or(&"").trim().to_string(),
591                })
592            } else {
593                None
594            }
595        })
596        .collect()
597}
598
599/// Get commits between two refs, optionally filtered to a path.
600pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
601    get_commits_between_paths(
602        from,
603        to,
604        &path_filter
605            .into_iter()
606            .map(String::from)
607            .collect::<Vec<_>>(),
608    )
609}
610
611/// Get commits between two refs, filtered to multiple paths (git log -- path1 path2 ...).
612pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
613    let range = format!("{}..{}", from, to);
614    let mut args = vec![
615        "-c".to_string(),
616        "log.showSignature=false".to_string(),
617        "log".to_string(),
618        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
619        range,
620    ];
621    if !paths.is_empty() {
622        args.push("--".to_string());
623        for p in paths {
624            args.push(p.clone());
625        }
626    }
627    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
628    let output = git_output(&arg_refs)?;
629    Ok(parse_commit_output(&output))
630}
631
632/// Get all commits reachable from HEAD, optionally filtered to a path.
633/// Used for initial releases where there is no previous tag.
634pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
635    get_all_commits_paths(
636        &path_filter
637            .into_iter()
638            .map(String::from)
639            .collect::<Vec<_>>(),
640    )
641}
642
643/// Get all commits reachable from HEAD, filtered to multiple paths.
644pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
645    let mut args = vec![
646        "-c".to_string(),
647        "log.showSignature=false".to_string(),
648        "log".to_string(),
649        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
650        "HEAD".to_string(),
651    ];
652    if !paths.is_empty() {
653        args.push("--".to_string());
654        for p in paths {
655            args.push(p.clone());
656        }
657    }
658    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
659    let output = git_output(&arg_refs)?;
660    Ok(parse_commit_output(&output))
661}
662
663/// Collect semver tags from the output of the given `git` arguments, filtered
664/// by `prefix` and sorted descending by version. When `git_config` is
665/// provided, applies `ignore_tags` (glob match) and `ignore_tag_prefixes`
666/// (starts_with) filters; both lists are template-rendered when
667/// `template_vars` is provided.
668fn collect_semver_tags(
669    git_args: &[&str],
670    prefix: &str,
671    git_config: Option<&GitConfig>,
672    template_vars: Option<&TemplateVars>,
673) -> Result<Vec<String>> {
674    let tags_output = git_output(git_args)?;
675    if tags_output.is_empty() {
676        return Ok(vec![]);
677    }
678
679    let (rendered_ignore_tags, rendered_ignore_prefixes) =
680        render_ignore_patterns(git_config, template_vars);
681    let ignore_tag_globs: Vec<glob::Pattern> = rendered_ignore_tags
682        .iter()
683        .filter_map(|pat| glob::Pattern::new(pat).ok())
684        .collect();
685
686    let mut matching: Vec<(SemVer, String)> = tags_output
687        .lines()
688        .filter(|t| t.starts_with(prefix))
689        .filter(|t| !ignore_tag_globs.iter().any(|g| g.matches(t)))
690        .filter(|t| {
691            !rendered_ignore_prefixes
692                .iter()
693                .any(|p| !p.is_empty() && t.starts_with(p))
694        })
695        .filter_map(|t| parse_semver_tag(t).ok().map(|v| (v, t.to_string())))
696        .collect();
697    matching.sort_by(|a, b| b.0.cmp(&a.0));
698    Ok(matching.into_iter().map(|(_, tag)| tag).collect())
699}
700
701/// Get all semver tags in the repo, sorted descending by version.
702/// Prerelease tags sort after release tags of the same major.minor.patch.
703///
704/// When `git_config` is provided, applies `ignore_tags` (glob match) and
705/// `ignore_tag_prefixes` (starts_with) filters. When `template_vars` is
706/// provided, both lists are template-rendered first.
707pub fn get_all_semver_tags(
708    prefix: &str,
709    git_config: Option<&GitConfig>,
710    template_vars: Option<&TemplateVars>,
711) -> Result<Vec<String>> {
712    collect_semver_tags(&["tag", "--list"], prefix, git_config, template_vars)
713}
714
715/// Get semver tags reachable from HEAD, sorted descending by version.
716/// Prerelease tags sort after release tags of the same major.minor.patch.
717///
718/// Same filtering semantics as [`get_all_semver_tags`].
719pub fn get_branch_semver_tags(
720    prefix: &str,
721    git_config: Option<&GitConfig>,
722    template_vars: Option<&TemplateVars>,
723) -> Result<Vec<String>> {
724    collect_semver_tags(
725        &["tag", "--merged", "HEAD", "--list"],
726        prefix,
727        git_config,
728        template_vars,
729    )
730}
731
732/// Create an annotated tag and push it if an `origin` remote exists.
733pub fn create_and_push_tag(
734    tag: &str,
735    message: &str,
736    dry_run: bool,
737    log: &crate::log::StageLogger,
738    strict: bool,
739) -> Result<()> {
740    if dry_run {
741        log.status(&format!(
742            "(dry-run) would create tag: {} (\"{}\")",
743            tag, message
744        ));
745        return Ok(());
746    }
747    git_output(&["tag", "-a", tag, "-m", message])?;
748
749    let has_remote = std::process::Command::new("git")
750        .args(["remote", "get-url", "origin"])
751        .output()
752        .map(|o| o.status.success())
753        .unwrap_or(false);
754
755    if has_remote {
756        git_output(&["push", "origin", tag])?;
757    } else if strict {
758        anyhow::bail!("no 'origin' remote found, cannot push tag (strict mode)");
759    } else {
760        log.warn("no 'origin' remote found, skipping push");
761    }
762    Ok(())
763}
764
765/// GET a GitHub API endpoint via the `gh` CLI (single request, no pagination).
766///
767/// Returns the parsed JSON response. Useful for endpoints that return a single
768/// object (e.g. the Compare API) rather than a paginated array.
769pub fn gh_api_get(endpoint: &str, token: Option<&str>) -> Result<serde_json::Value> {
770    let mut cmd = Command::new("gh");
771    cmd.args(["api", endpoint]);
772    if let Some(tok) = token {
773        cmd.env("GITHUB_TOKEN", tok);
774    }
775    let output = cmd
776        .stdout(std::process::Stdio::piped())
777        .stderr(std::process::Stdio::piped())
778        .output()
779        .context("failed to spawn gh CLI")?;
780    if !output.status.success() {
781        let stderr = String::from_utf8_lossy(&output.stderr);
782        bail!("gh api GET {} failed: {}", endpoint, stderr.trim());
783    }
784    let stdout = String::from_utf8_lossy(&output.stdout);
785    serde_json::from_str(&stdout).context("failed to parse gh api response")
786}
787
788/// GET a GitHub API endpoint via the `gh` CLI, with pagination.
789///
790/// Returns a JSON array of all pages concatenated. The caller is responsible for
791/// ensuring that `gh` is installed and authenticated.
792pub fn gh_api_get_paginated(endpoint: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
793    let mut cmd = Command::new("gh");
794    cmd.args(["api", "--paginate", endpoint]);
795    if let Some(tok) = token {
796        cmd.env("GITHUB_TOKEN", tok);
797    }
798    let output = cmd
799        .stdout(std::process::Stdio::piped())
800        .stderr(std::process::Stdio::piped())
801        .output()
802        .context("failed to spawn gh CLI")?;
803
804    if !output.status.success() {
805        let stderr = String::from_utf8_lossy(&output.stderr);
806        bail!("gh api GET {} failed: {}", endpoint, stderr.trim());
807    }
808
809    let stdout = String::from_utf8_lossy(&output.stdout);
810
811    // Try parsing the entire response first before falling back to splitting.
812    // This avoids the split_inclusive(']') approach corrupting non-array responses.
813    if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&stdout) {
814        return Ok(arr);
815    }
816    if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
817        // Single object response (e.g. non-list endpoint) — wrap in a vec.
818        return Ok(vec![val]);
819    }
820
821    // Whole-parse failed — gh --paginate may return multiple JSON arrays
822    // concatenated (e.g. `[...][...]`). Split on `]` boundaries and parse each chunk.
823    let mut all_items = Vec::new();
824    for chunk in stdout.split_inclusive(']') {
825        let trimmed = chunk.trim();
826        if trimmed.is_empty() {
827            continue;
828        }
829        if let Ok(serde_json::Value::Array(arr)) =
830            serde_json::from_str::<serde_json::Value>(trimmed)
831        {
832            all_items.extend(arr);
833        } else if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
834            all_items.push(val);
835        } else {
836            // Log unparseable chunks so corrupt data doesn't go unnoticed.
837            eprintln!(
838                "warning: gh_api_get_paginated: failed to parse JSON chunk (len={}): {:?}",
839                trimmed.len(),
840                &trimmed[..trimmed.len().min(200)]
841            );
842        }
843    }
844    Ok(all_items)
845}
846
847/// POST a JSON body to a GitHub API endpoint via the `gh` CLI.
848///
849/// Returns the parsed JSON response on success. The caller is responsible for
850/// ensuring that `gh` is installed and authenticated.
851fn gh_api_post(endpoint: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
852    use std::io::Write;
853
854    let body_str = serde_json::to_string(body)?;
855
856    let mut child = Command::new("gh")
857        .args(["api", "--method", "POST", endpoint, "--input", "-"])
858        .stdin(std::process::Stdio::piped())
859        .stdout(std::process::Stdio::piped())
860        .stderr(std::process::Stdio::piped())
861        .spawn()
862        .context("failed to spawn gh CLI")?;
863
864    if let Some(ref mut stdin) = child.stdin {
865        stdin.write_all(body_str.as_bytes())?;
866    }
867    child.stdin.take(); // close stdin
868
869    let output = child.wait_with_output()?;
870    if !output.status.success() {
871        let stderr = String::from_utf8_lossy(&output.stderr);
872        bail!("gh api POST {} failed: {}", endpoint, stderr.trim());
873    }
874
875    let response: serde_json::Value = serde_json::from_slice(&output.stdout)
876        .with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
877    Ok(response)
878}
879
880/// Create a tag via the GitHub API (using the `gh` CLI).
881///
882/// This avoids the need for local git push access. Requires the `gh` CLI to be
883/// installed and authenticated (`gh auth login`). The GitHub API creates a
884/// lightweight tag object pointing at the HEAD commit on the default branch.
885///
886/// Falls back to [`create_and_push_tag`] if `gh` is not available.
887pub fn create_tag_via_github_api(
888    tag: &str,
889    message: &str,
890    dry_run: bool,
891    log: &crate::log::StageLogger,
892    strict: bool,
893) -> Result<()> {
894    if dry_run {
895        log.status(&format!(
896            "(dry-run) would create tag via GitHub API: {} (\"{}\")",
897            tag, message
898        ));
899        return Ok(());
900    }
901
902    // Detect owner/repo from the origin remote.
903    let (owner, repo) = detect_github_repo()?;
904
905    // Get the current HEAD SHA to point the tag at.
906    let sha = git_output(&["rev-parse", "HEAD"])?;
907
908    // Step 1: Create the tag object
909    let body = serde_json::json!({
910        "tag": tag,
911        "message": message,
912        "object": sha,
913        "type": "commit",
914        "tagger": {
915            "name": git_output(&["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
916            "email": git_output(&["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
917            "date": chrono::Utc::now().to_rfc3339(),
918        }
919    });
920
921    let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
922    let response = match gh_api_post(&tag_endpoint, &body) {
923        Ok(resp) => resp,
924        Err(e) => {
925            if e.to_string().contains("failed to spawn gh CLI") {
926                if strict {
927                    anyhow::bail!(
928                        "gh CLI not found, cannot create tag via GitHub API (strict mode)"
929                    );
930                }
931                log.warn("gh CLI not found, falling back to local git tag + push");
932                return create_and_push_tag(tag, message, dry_run, log, strict);
933            }
934            return Err(e);
935        }
936    };
937
938    let tag_sha = response["sha"]
939        .as_str()
940        .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
941
942    // Step 2: Create the ref pointing to the tag object
943    let ref_body = serde_json::json!({
944        "ref": format!("refs/tags/{}", tag),
945        "sha": tag_sha,
946    });
947
948    let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
949    gh_api_post(&ref_endpoint, &ref_body)?;
950
951    Ok(())
952}
953
954/// Get last N commit subjects.
955pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
956    let output = git_output(&[
957        "-c",
958        "log.showSignature=false",
959        "log",
960        &format!("-{count}"),
961        "--pretty=format:%s",
962    ])?;
963    Ok(output.lines().map(str::to_string).collect())
964}
965
966/// Get commit subjects between two refs.
967pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
968    let output = git_output(&[
969        "-c",
970        "log.showSignature=false",
971        "log",
972        "--pretty=format:%s",
973        &format!("{from}..{to}"),
974    ])?;
975    Ok(output.lines().map(str::to_string).collect())
976}
977
978/// Get the current branch name.
979pub fn get_current_branch() -> Result<String> {
980    git_output(&["rev-parse", "--abbrev-ref", "HEAD"])
981}
982
983/// Check if there are any commits since a given tag.
984pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
985    let range = format!("{}..HEAD", tag);
986    let output = git_output(&["-c", "log.showSignature=false", "log", "--oneline", &range])?;
987    Ok(!output.is_empty())
988}
989
990/// Get the short commit hash of HEAD.
991pub fn get_short_commit() -> Result<String> {
992    git_output(&["rev-parse", "--short", "HEAD"])
993}
994
995/// Check if there are changes in a path since a given tag.
996pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
997    let output = git_output(&["diff", "--name-only", &format!("{}..HEAD", tag), "--", path])?;
998    Ok(!output.is_empty())
999}
1000
1001/// Get last N commit subjects that touched a specific path.
1002pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
1003    let output = git_output(&[
1004        "-c",
1005        "log.showSignature=false",
1006        "log",
1007        &format!("-{count}"),
1008        "--pretty=format:%s",
1009        "--",
1010        path,
1011    ])?;
1012    Ok(output.lines().map(str::to_string).collect())
1013}
1014
1015/// Get commit subjects between two refs that touched a specific path.
1016pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
1017    let output = git_output(&[
1018        "-c",
1019        "log.showSignature=false",
1020        "log",
1021        "--pretty=format:%s",
1022        &format!("{from}..{to}"),
1023        "--",
1024        path,
1025    ])?;
1026    Ok(output.lines().map(str::to_string).collect())
1027}
1028
1029/// Stage specific files and create a commit.
1030pub fn stage_and_commit(files: &[&str], message: &str) -> Result<()> {
1031    let mut args = vec!["add", "--"];
1032    args.extend(files.iter().copied());
1033    git_output(&args)?;
1034    git_output(&["commit", "-m", message])?;
1035    Ok(())
1036}
1037
1038/// Parse owner and repo name from a GitHub remote URL.
1039/// Supports HTTPS (`https://github.com/owner/repo.git`) and SSH (`git@github.com:owner/repo.git`).
1040pub fn parse_github_remote(url: &str) -> Option<(String, String)> {
1041    let url = url.trim();
1042    if url.is_empty() {
1043        return None;
1044    }
1045
1046    // Strip trailing ".git" if present
1047    let url = url.strip_suffix(".git").unwrap_or(url);
1048
1049    // HTTPS: https://github.com/owner/repo
1050    if let Some(path) = url.strip_prefix("https://github.com/") {
1051        let parts: Vec<&str> = path.splitn(3, '/').collect();
1052        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
1053            return Some((parts[0].to_string(), parts[1].to_string()));
1054        }
1055    }
1056
1057    // SSH: git@github.com:owner/repo
1058    if let Some(path) = url.strip_prefix("git@github.com:") {
1059        let parts: Vec<&str> = path.splitn(3, '/').collect();
1060        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
1061            return Some((parts[0].to_string(), parts[1].to_string()));
1062        }
1063    }
1064
1065    None
1066}
1067
1068/// Get the GitHub owner/name from the `origin` remote.
1069pub fn detect_github_repo() -> Result<(String, String)> {
1070    let url = git_output(&["remote", "get-url", "origin"])?;
1071    parse_github_remote(&url).ok_or_else(|| {
1072        anyhow::anyhow!("could not parse GitHub owner/repo from remote URL: {}", url)
1073    })
1074}
1075
1076/// Parse owner and repo from any git remote URL, regardless of host.
1077///
1078/// Supports HTTPS (`https://host/owner/repo.git`) and SSH (`git@host:owner/repo.git`)
1079/// formats. Returns `(owner, repo)` with `.git` suffix stripped.
1080///
1081/// This is a host-agnostic version of [`parse_github_remote`], suitable for
1082/// GitLab, Gitea, and other SCM providers.
1083pub fn parse_remote_owner_repo(url: &str) -> Option<(String, String)> {
1084    let url = url.trim();
1085    if url.is_empty() {
1086        return None;
1087    }
1088
1089    // Strip trailing ".git" if present
1090    let url = url.strip_suffix(".git").unwrap_or(url);
1091
1092    // HTTPS: https://host/owner/repo or https://host/group/subgroup/repo
1093    if url.starts_with("https://") || url.starts_with("http://") {
1094        // Strip scheme and host
1095        let after_scheme = if let Some(rest) = url.strip_prefix("https://") {
1096            rest
1097        } else {
1098            url.strip_prefix("http://")?
1099        };
1100        // Strip any credentials (user:pass@host or user@host)
1101        let after_host = after_scheme.find('/').map(|i| &after_scheme[i + 1..])?;
1102        // For nested groups (e.g. group/subgroup/repo), the owner is everything
1103        // up to the last slash.
1104        let last_slash = after_host.rfind('/')?;
1105        let owner = &after_host[..last_slash];
1106        let repo = &after_host[last_slash + 1..];
1107        if !owner.is_empty() && !repo.is_empty() {
1108            return Some((owner.to_string(), repo.to_string()));
1109        }
1110    }
1111
1112    // SSH: git@host:owner/repo or git@host:group/subgroup/repo
1113    if let Some(colon_pos) = url.find(':') {
1114        let before_colon = &url[..colon_pos];
1115        // Ensure it looks like an SSH URL (contains @, no //)
1116        if before_colon.contains('@') && !before_colon.contains("//") {
1117            let path = &url[colon_pos + 1..];
1118            let last_slash = path.rfind('/')?;
1119            let owner = &path[..last_slash];
1120            let repo = &path[last_slash + 1..];
1121            if !owner.is_empty() && !repo.is_empty() {
1122                return Some((owner.to_string(), repo.to_string()));
1123            }
1124        }
1125    }
1126
1127    None
1128}
1129
1130/// Get the owner/repo from the `origin` remote, regardless of SCM host.
1131///
1132/// Uses [`parse_remote_owner_repo`] which works with any git hosting provider
1133/// (GitHub, GitLab, Gitea, etc.).
1134pub fn detect_owner_repo() -> Result<(String, String)> {
1135    let url = git_output(&["remote", "get-url", "origin"])?;
1136    parse_remote_owner_repo(&url)
1137        .ok_or_else(|| anyhow::anyhow!("could not parse owner/repo from remote URL: {}", url))
1138}
1139
1140/// Find the tag immediately before `current_tag` in commit history.
1141///
1142/// Uses `git describe --tags --abbrev=0 {current_tag}^` to locate the previous
1143/// tag. When `git_config` is provided, applies `--exclude` flags for both
1144/// `ignore_tags` patterns and `ignore_tag_prefixes` (converted to `<prefix>*`
1145/// globs), so git handles all filtering natively in a single call.
1146///
1147/// Both `ignore_tags` and `ignore_tag_prefixes` are rendered through the
1148/// template engine when `template_vars` is provided.
1149///
1150/// If that fails (e.g. `current_tag` is the very first tag), falls back to
1151/// returning `None`.
1152///
1153/// **Note:** This variant is not monorepo-aware — in a monorepo, use
1154/// [`find_previous_tag_with_prefix`] to ensure only tags from the same
1155/// subproject are considered.
1156pub fn find_previous_tag(
1157    current_tag: &str,
1158    git_config: Option<&GitConfig>,
1159    template_vars: Option<&TemplateVars>,
1160) -> Result<Option<String>> {
1161    find_previous_tag_with_prefix(current_tag, git_config, template_vars, None)
1162}
1163
1164/// Like [`find_previous_tag`], but with optional monorepo prefix filtering.
1165///
1166/// When `monorepo_prefix` is `Some`, adds `--match=<prefix>*` to the
1167/// `git describe` call so only tags from the same subproject are considered.
1168/// The full tag (with prefix) is returned.
1169pub fn find_previous_tag_with_prefix(
1170    current_tag: &str,
1171    git_config: Option<&GitConfig>,
1172    template_vars: Option<&TemplateVars>,
1173    monorepo_prefix: Option<&str>,
1174) -> Result<Option<String>> {
1175    let parent_ref = format!("{}^", current_tag);
1176
1177    // Use the shared helper to render both ignore_tags and ignore_tag_prefixes.
1178    let (rendered_ignore_tags, rendered_ignore_prefixes) =
1179        render_ignore_patterns(git_config, template_vars);
1180
1181    // Build args: `git describe --tags --abbrev=0 --exclude=<pattern> ... <parent_ref>`
1182    // Include both ignore_tags (as-is, they're glob patterns) and
1183    // ignore_tag_prefixes (converted to `<prefix>*` globs).
1184    let mut exclude_args: Vec<String> = rendered_ignore_tags
1185        .iter()
1186        .map(|t| format!("--exclude={}", t))
1187        .collect();
1188    for pfx in &rendered_ignore_prefixes {
1189        exclude_args.push(format!("--exclude={}*", pfx));
1190    }
1191
1192    // When monorepo_prefix is set, constrain git describe to only consider
1193    // tags matching this prefix. Without this, git describe would return
1194    // the nearest reachable tag from ANY subproject.
1195    let match_arg;
1196    let mut args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
1197    if let Some(prefix) = monorepo_prefix {
1198        match_arg = format!("--match={}*", prefix);
1199        args.push(&match_arg);
1200    }
1201    for ea in &exclude_args {
1202        args.push(ea.as_str());
1203    }
1204    args.push(&parent_ref);
1205
1206    match git_output(&args) {
1207        Ok(tag) if !tag.is_empty() => Ok(Some(tag)),
1208        _ => Ok(None),
1209    }
1210}
1211
1212/// Return the SHA of the very first commit in the repository.
1213///
1214/// Runs `git rev-list --max-parents=0 HEAD` and returns the first line
1215/// (repositories with multiple roots will return the oldest).
1216pub fn get_first_commit() -> Result<String> {
1217    let output = git_output(&["rev-list", "--max-parents=0", "HEAD"])?;
1218    // In repos with multiple roots, take the last line (oldest commit).
1219    output
1220        .lines()
1221        .last()
1222        .map(|s| s.to_string())
1223        .ok_or_else(|| anyhow::anyhow!("no commits found in repository"))
1224}
1225
1226/// Check whether `tag` points at the current HEAD commit.
1227///
1228/// Compares the dereferenced tag object (`git rev-parse {tag}^{{}}`) with
1229/// `git rev-parse HEAD`. Returns `false` if either command fails.
1230///
1231/// Works with any tag name including monorepo-prefixed tags (e.g.
1232/// `subproject1/v1.2.3`), since `git rev-parse` resolves tag refs by
1233/// name regardless of slashes or prefixes.
1234pub fn tag_points_at_head(tag: &str) -> Result<bool> {
1235    let deref = format!("{}^{{}}", tag);
1236    let tag_sha = git_output(&["rev-parse", &deref])?;
1237    let head_sha = git_output(&["rev-parse", "HEAD"])?;
1238    Ok(tag_sha == head_sha)
1239}
1240
1241/// Check whether `git` is available in PATH.
1242pub fn check_git_available() -> Result<()> {
1243    let output = Command::new("git").arg("--version").output();
1244    match output {
1245        Ok(o) if o.status.success() => Ok(()),
1246        _ => bail!("git is not installed or not in PATH. Install git and try again."),
1247    }
1248}
1249
1250/// Check whether the current directory is inside a git repository.
1251pub fn is_git_repo() -> bool {
1252    git_output(&["rev-parse", "--git-dir"]).is_ok()
1253}
1254
1255/// Return the `git status --porcelain` output showing dirty files.
1256pub fn git_status_porcelain() -> String {
1257    git_output(&["status", "--porcelain"]).unwrap_or_default()
1258}
1259
1260/// Check whether the current repository is a shallow clone.
1261///
1262/// Returns `true` if the `.git/shallow` sentinel file exists, which git creates
1263/// when a repository was cloned with `--depth`.
1264pub fn is_shallow_clone() -> bool {
1265    // Use `git rev-parse --git-dir` to find the actual .git directory,
1266    // which handles worktrees and non-standard layouts.
1267    let git_dir = git_output(&["rev-parse", "--git-dir"]).unwrap_or_else(|_| ".git".to_string());
1268    std::path::Path::new(&git_dir).join("shallow").exists()
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273    use super::*;
1274
1275    #[test]
1276    fn test_parse_semver() {
1277        let v = parse_semver("v1.2.3").unwrap();
1278        assert_eq!(v.major, 1);
1279        assert_eq!(v.minor, 2);
1280        assert_eq!(v.patch, 3);
1281        assert_eq!(v.prerelease, None);
1282        assert_eq!(v.build_metadata, None);
1283    }
1284
1285    #[test]
1286    fn test_parse_semver_prerelease() {
1287        let v = parse_semver("v1.0.0-rc.1").unwrap();
1288        assert_eq!(v.major, 1);
1289        assert_eq!(v.prerelease, Some("rc.1".to_string()));
1290        assert_eq!(v.build_metadata, None);
1291    }
1292
1293    #[test]
1294    fn test_parse_semver_build_metadata() {
1295        let v = parse_semver("v1.0.0+build.42").unwrap();
1296        assert_eq!(v.major, 1);
1297        assert_eq!(v.minor, 0);
1298        assert_eq!(v.patch, 0);
1299        assert_eq!(v.prerelease, None);
1300        assert_eq!(v.build_metadata, Some("build.42".to_string()));
1301    }
1302
1303    #[test]
1304    fn test_parse_semver_prerelease_and_build_metadata() {
1305        let v = parse_semver("v1.0.0-rc.1+build.42").unwrap();
1306        assert_eq!(v.major, 1);
1307        assert_eq!(v.prerelease, Some("rc.1".to_string()));
1308        assert_eq!(v.build_metadata, Some("build.42".to_string()));
1309    }
1310
1311    #[test]
1312    fn test_parse_semver_rejects_prefix() {
1313        // Strict parse_semver rejects prefixed tags (use parse_semver_tag instead)
1314        assert!(parse_semver("cfgd-core-v2.1.0").is_err());
1315        assert!(parse_semver("release-notes-v1.2.3").is_err());
1316    }
1317
1318    #[test]
1319    fn test_parse_semver_tag_with_prefix() {
1320        let v = parse_semver_tag("cfgd-core-v2.1.0").unwrap();
1321        assert_eq!(v.major, 2);
1322        assert_eq!(v.minor, 1);
1323        assert_eq!(v.patch, 0);
1324    }
1325
1326    #[test]
1327    fn test_parse_semver_tag_plain() {
1328        // parse_semver_tag also handles plain versions
1329        let v = parse_semver_tag("v1.2.3").unwrap();
1330        assert_eq!(v.major, 1);
1331        assert_eq!(v.minor, 2);
1332        assert_eq!(v.patch, 3);
1333    }
1334
1335    #[test]
1336    fn test_parse_semver_tag_with_prerelease_prefix() {
1337        let v = parse_semver_tag("my-project-v1.0.0-rc.1").unwrap();
1338        assert_eq!(v.major, 1);
1339        assert_eq!(v.prerelease, Some("rc.1".to_string()));
1340    }
1341
1342    #[test]
1343    fn test_is_prerelease() {
1344        assert!(parse_semver("v1.0.0-rc.1").unwrap().is_prerelease());
1345        assert!(!parse_semver("v1.0.0").unwrap().is_prerelease());
1346        // Build metadata only is NOT a prerelease
1347        assert!(!parse_semver("v1.0.0+build.42").unwrap().is_prerelease());
1348    }
1349
1350    #[test]
1351    fn test_parse_github_remote_https() {
1352        let result = parse_github_remote("https://github.com/tj-smith47/anodizer.git");
1353        assert_eq!(
1354            result,
1355            Some(("tj-smith47".to_string(), "anodizer".to_string()))
1356        );
1357    }
1358
1359    #[test]
1360    fn test_parse_github_remote_https_no_dotgit() {
1361        let result = parse_github_remote("https://github.com/owner/repo");
1362        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
1363    }
1364
1365    #[test]
1366    fn test_parse_github_remote_ssh() {
1367        let result = parse_github_remote("git@github.com:owner/repo.git");
1368        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
1369    }
1370
1371    #[test]
1372    fn test_parse_github_remote_ssh_no_dotgit() {
1373        let result = parse_github_remote("git@github.com:owner/repo");
1374        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
1375    }
1376
1377    #[test]
1378    fn test_parse_github_remote_invalid() {
1379        let result = parse_github_remote("https://gitlab.com/foo/bar.git");
1380        assert_eq!(result, None);
1381    }
1382
1383    #[test]
1384    fn test_parse_github_remote_empty() {
1385        let result = parse_github_remote("");
1386        assert_eq!(result, None);
1387    }
1388
1389    // -- parse_remote_owner_repo (generic) -----------------------------------
1390
1391    #[test]
1392    fn test_parse_remote_github_https() {
1393        let result = parse_remote_owner_repo("https://github.com/owner/repo.git");
1394        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
1395    }
1396
1397    #[test]
1398    fn test_parse_remote_gitlab_https() {
1399        let result = parse_remote_owner_repo("https://gitlab.com/owner/repo.git");
1400        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
1401    }
1402
1403    #[test]
1404    fn test_parse_remote_gitea_https() {
1405        let result = parse_remote_owner_repo("https://gitea.example.com/myorg/myapp.git");
1406        assert_eq!(result, Some(("myorg".to_string(), "myapp".to_string())));
1407    }
1408
1409    #[test]
1410    fn test_parse_remote_gitlab_nested_group() {
1411        let result = parse_remote_owner_repo("https://gitlab.com/group/subgroup/repo.git");
1412        assert_eq!(
1413            result,
1414            Some(("group/subgroup".to_string(), "repo".to_string()))
1415        );
1416    }
1417
1418    #[test]
1419    fn test_parse_remote_ssh_gitlab() {
1420        let result = parse_remote_owner_repo("git@gitlab.com:owner/repo.git");
1421        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
1422    }
1423
1424    #[test]
1425    fn test_parse_remote_ssh_gitea() {
1426        let result = parse_remote_owner_repo("git@gitea.example.com:org/app.git");
1427        assert_eq!(result, Some(("org".to_string(), "app".to_string())));
1428    }
1429
1430    #[test]
1431    fn test_parse_remote_ssh_nested_group() {
1432        let result = parse_remote_owner_repo("git@gitlab.com:group/subgroup/repo.git");
1433        assert_eq!(
1434            result,
1435            Some(("group/subgroup".to_string(), "repo".to_string()))
1436        );
1437    }
1438
1439    #[test]
1440    fn test_parse_remote_no_dotgit() {
1441        let result = parse_remote_owner_repo("https://gitlab.com/owner/repo");
1442        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
1443    }
1444
1445    #[test]
1446    fn test_parse_remote_empty() {
1447        assert_eq!(parse_remote_owner_repo(""), None);
1448    }
1449
1450    #[test]
1451    fn test_parse_remote_http() {
1452        let result = parse_remote_owner_repo("http://gitlab.local/team/project.git");
1453        assert_eq!(result, Some(("team".to_string(), "project".to_string())));
1454    }
1455
1456    #[test]
1457    fn test_strip_url_credentials_with_userinfo() {
1458        assert_eq!(
1459            strip_url_credentials("https://user:token@github.com/owner/repo.git"),
1460            "https://github.com/owner/repo.git"
1461        );
1462    }
1463
1464    #[test]
1465    fn test_strip_url_credentials_no_userinfo() {
1466        assert_eq!(
1467            strip_url_credentials("https://github.com/owner/repo.git"),
1468            "https://github.com/owner/repo.git"
1469        );
1470    }
1471
1472    #[test]
1473    fn test_strip_url_credentials_ssh_unchanged() {
1474        assert_eq!(
1475            strip_url_credentials("git@github.com:owner/repo.git"),
1476            "git@github.com:owner/repo.git"
1477        );
1478    }
1479
1480    #[test]
1481    fn test_strip_url_credentials_user_only() {
1482        assert_eq!(
1483            strip_url_credentials("https://user@github.com/owner/repo.git"),
1484            "https://github.com/owner/repo.git"
1485        );
1486    }
1487
1488    #[test]
1489    fn test_compare_prerelease_numeric() {
1490        // rc.9 < rc.10 (numeric comparison, not lexicographic)
1491        assert_eq!(
1492            compare_prerelease("rc.9", "rc.10"),
1493            std::cmp::Ordering::Less
1494        );
1495        assert_eq!(
1496            compare_prerelease("rc.10", "rc.9"),
1497            std::cmp::Ordering::Greater
1498        );
1499    }
1500
1501    #[test]
1502    fn test_compare_prerelease_numeric_less_than_alpha() {
1503        // Numeric identifiers always have lower precedence than alphanumeric
1504        assert_eq!(compare_prerelease("1", "alpha"), std::cmp::Ordering::Less);
1505        assert_eq!(
1506            compare_prerelease("alpha", "1"),
1507            std::cmp::Ordering::Greater
1508        );
1509    }
1510
1511    #[test]
1512    fn test_compare_prerelease_alpha_lexicographic() {
1513        assert_eq!(
1514            compare_prerelease("alpha", "beta"),
1515            std::cmp::Ordering::Less
1516        );
1517    }
1518
1519    #[test]
1520    fn test_compare_prerelease_shorter_lower_precedence() {
1521        // alpha < alpha.1 (shorter set = lower precedence)
1522        assert_eq!(
1523            compare_prerelease("alpha", "alpha.1"),
1524            std::cmp::Ordering::Less
1525        );
1526    }
1527
1528    #[test]
1529    fn test_compare_prerelease_equal() {
1530        assert_eq!(
1531            compare_prerelease("rc.1", "rc.1"),
1532            std::cmp::Ordering::Equal
1533        );
1534    }
1535
1536    #[test]
1537    fn test_semver_ord_prerelease_less_than_release() {
1538        let pre = parse_semver("v1.0.0-rc.1").unwrap();
1539        let rel = parse_semver("v1.0.0").unwrap();
1540        assert!(pre < rel);
1541    }
1542
1543    #[test]
1544    fn test_semver_ord_prerelease_numeric_sorting() {
1545        // v1.0.0-rc.9 < v1.0.0-rc.10 (SemVer 2.0.0 compliant)
1546        let rc9 = parse_semver("v1.0.0-rc.9").unwrap();
1547        let rc10 = parse_semver("v1.0.0-rc.10").unwrap();
1548        assert!(rc9 < rc10);
1549    }
1550
1551    // -----------------------------------------------------------------------
1552    // find_latest_tag_matching + GitConfig integration tests
1553    //
1554    // Each test creates a fresh temporary git repository with tags, then
1555    // verifies that GitConfig fields (ignore_tags, ignore_tag_prefixes, etc.)
1556    // are respected.
1557    // -----------------------------------------------------------------------
1558
1559    use serial_test::serial;
1560
1561    /// Create a bare-bones git repo in `dir` with an initial commit and the
1562    /// given list of lightweight tags.
1563    fn init_repo_with_tags(dir: &std::path::Path, tags: &[&str]) {
1564        use std::process::Command;
1565
1566        let run = |args: &[&str]| {
1567            let out = Command::new("git")
1568                .args(args)
1569                .current_dir(dir)
1570                .env("GIT_AUTHOR_NAME", "test")
1571                .env("GIT_AUTHOR_EMAIL", "test@test.com")
1572                .env("GIT_COMMITTER_NAME", "test")
1573                .env("GIT_COMMITTER_EMAIL", "test@test.com")
1574                .output()
1575                .unwrap();
1576            assert!(
1577                out.status.success(),
1578                "git {:?} failed: {}",
1579                args,
1580                String::from_utf8_lossy(&out.stderr)
1581            );
1582        };
1583
1584        run(&["init"]);
1585        run(&["config", "user.email", "test@test.com"]);
1586        run(&["config", "user.name", "test"]);
1587        std::fs::write(dir.join("README"), "init").unwrap();
1588        run(&["add", "."]);
1589        run(&["commit", "-m", "initial"]);
1590
1591        for tag in tags {
1592            run(&["tag", tag]);
1593        }
1594    }
1595
1596    #[test]
1597    #[serial]
1598    fn test_find_latest_tag_none_config_unchanged_behavior() {
1599        let tmp = tempfile::tempdir().unwrap();
1600        let dir = tmp.path();
1601        init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v2.0.0"]);
1602
1603        // Change to the temp repo so git commands work.
1604        let orig = std::env::current_dir().unwrap();
1605        std::env::set_current_dir(dir).unwrap();
1606
1607        let result = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
1608        assert_eq!(result, Some("v2.0.0".to_string()));
1609
1610        std::env::set_current_dir(orig).unwrap();
1611    }
1612
1613    #[test]
1614    #[serial]
1615    fn test_get_all_semver_tags_ignore_tags() {
1616        // The tag subcommand's find_previous_tag calls through to
1617        // get_all_semver_tags; its ignore_tags wiring must exclude matching
1618        // tags so an autotag pass doesn't regress onto a deliberately-ignored
1619        // tag (e.g. a withdrawn release).
1620        let tmp = tempfile::tempdir().unwrap();
1621        let dir = tmp.path();
1622        init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
1623
1624        let orig = std::env::current_dir().unwrap();
1625        std::env::set_current_dir(dir).unwrap();
1626
1627        let gc = crate::config::GitConfig {
1628            ignore_tags: Some(vec!["v3.0.0".to_string()]),
1629            ..Default::default()
1630        };
1631        let tags = get_all_semver_tags("v", Some(&gc), None).unwrap();
1632        assert_eq!(tags, vec!["v2.0.0".to_string(), "v1.0.0".to_string()]);
1633
1634        std::env::set_current_dir(orig).unwrap();
1635    }
1636
1637    #[test]
1638    #[serial]
1639    fn test_get_all_semver_tags_ignore_tag_prefixes() {
1640        let tmp = tempfile::tempdir().unwrap();
1641        let dir = tmp.path();
1642        init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "nightly-v3.0.0"]);
1643
1644        let orig = std::env::current_dir().unwrap();
1645        std::env::set_current_dir(dir).unwrap();
1646
1647        let gc = crate::config::GitConfig {
1648            ignore_tag_prefixes: Some(vec!["nightly-".to_string()]),
1649            ..Default::default()
1650        };
1651        let tags = get_all_semver_tags("", Some(&gc), None).unwrap();
1652        // "nightly-v3.0.0" is excluded by prefix; only v2, v1 survive, ordered desc.
1653        assert_eq!(tags, vec!["v2.0.0".to_string(), "v1.0.0".to_string()]);
1654
1655        std::env::set_current_dir(orig).unwrap();
1656    }
1657
1658    #[test]
1659    #[serial]
1660    fn test_get_all_semver_tags_no_config_unchanged() {
1661        let tmp = tempfile::tempdir().unwrap();
1662        let dir = tmp.path();
1663        init_repo_with_tags(dir, &["v1.0.0", "v2.0.0"]);
1664
1665        let orig = std::env::current_dir().unwrap();
1666        std::env::set_current_dir(dir).unwrap();
1667
1668        let tags = get_all_semver_tags("v", None, None).unwrap();
1669        assert_eq!(tags, vec!["v2.0.0".to_string(), "v1.0.0".to_string()]);
1670
1671        std::env::set_current_dir(orig).unwrap();
1672    }
1673
1674    #[test]
1675    #[serial]
1676    fn test_find_latest_tag_ignore_tags_exact_match() {
1677        let tmp = tempfile::tempdir().unwrap();
1678        let dir = tmp.path();
1679        init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
1680
1681        let orig = std::env::current_dir().unwrap();
1682        std::env::set_current_dir(dir).unwrap();
1683
1684        let gc = crate::config::GitConfig {
1685            ignore_tags: Some(vec!["v3.0.0".to_string()]),
1686            ..Default::default()
1687        };
1688        let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
1689        assert_eq!(result, Some("v2.0.0".to_string()));
1690
1691        std::env::set_current_dir(orig).unwrap();
1692    }
1693
1694    #[test]
1695    #[serial]
1696    fn test_find_latest_tag_ignore_tags_multiple() {
1697        let tmp = tempfile::tempdir().unwrap();
1698        let dir = tmp.path();
1699        init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
1700
1701        let orig = std::env::current_dir().unwrap();
1702        std::env::set_current_dir(dir).unwrap();
1703
1704        let gc = crate::config::GitConfig {
1705            ignore_tags: Some(vec!["v3.0.0".to_string(), "v2.0.0".to_string()]),
1706            ..Default::default()
1707        };
1708        let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
1709        assert_eq!(result, Some("v1.0.0".to_string()));
1710
1711        std::env::set_current_dir(orig).unwrap();
1712    }
1713
1714    #[test]
1715    #[serial]
1716    fn test_find_latest_tag_ignore_tag_prefixes() {
1717        let tmp = tempfile::tempdir().unwrap();
1718        let dir = tmp.path();
1719        init_repo_with_tags(
1720            dir,
1721            &["v1.0.0", "v2.0.0", "nightly-v3.0.0", "nightly-v4.0.0"],
1722        );
1723
1724        let orig = std::env::current_dir().unwrap();
1725        std::env::set_current_dir(dir).unwrap();
1726
1727        // Without prefix filtering, the template "v{{ .Version }}" won't match
1728        // nightly-v* tags anyway (regex mismatch). So test with a broader template
1729        // or with nightly-prefixed tags that do match a nightly template.
1730        // Let's test: filter out "nightly-" prefix from "nightly-v{{ .Version }}"
1731        let gc = crate::config::GitConfig {
1732            ignore_tag_prefixes: Some(vec!["nightly-".to_string()]),
1733            ..Default::default()
1734        };
1735        // The "v{{ .Version }}" template only matches v1.0.0, v2.0.0.
1736        // Without filtering, nightly tags don't match anyway, so latest = v2.0.0.
1737        let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
1738        assert_eq!(result, Some("v2.0.0".to_string()));
1739
1740        // Now test with a template that would match nightly tags too:
1741        // Use a nightly template. Without ignore_tag_prefixes, nightly-v4.0.0 wins.
1742        let result_nightly =
1743            find_latest_tag_matching("nightly-v{{ .Version }}", None, None).unwrap();
1744        assert_eq!(result_nightly, Some("nightly-v4.0.0".to_string()));
1745
1746        // With ignore_tag_prefixes filtering out "nightly-", all nightly tags are excluded.
1747        let result_filtered =
1748            find_latest_tag_matching("nightly-v{{ .Version }}", Some(&gc), None).unwrap();
1749        assert_eq!(result_filtered, None);
1750
1751        std::env::set_current_dir(orig).unwrap();
1752    }
1753
1754    #[test]
1755    #[serial]
1756    fn test_find_latest_tag_ignore_all_returns_none() {
1757        let tmp = tempfile::tempdir().unwrap();
1758        let dir = tmp.path();
1759        init_repo_with_tags(dir, &["v1.0.0", "v2.0.0"]);
1760
1761        let orig = std::env::current_dir().unwrap();
1762        std::env::set_current_dir(dir).unwrap();
1763
1764        let gc = crate::config::GitConfig {
1765            ignore_tags: Some(vec!["v1.0.0".to_string(), "v2.0.0".to_string()]),
1766            ..Default::default()
1767        };
1768        let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
1769        assert_eq!(result, None);
1770
1771        std::env::set_current_dir(orig).unwrap();
1772    }
1773
1774    #[test]
1775    #[serial]
1776    fn test_find_latest_tag_ignore_tags_and_prefixes_combined() {
1777        let tmp = tempfile::tempdir().unwrap();
1778        let dir = tmp.path();
1779        init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0-beta.1"]);
1780
1781        let orig = std::env::current_dir().unwrap();
1782        std::env::set_current_dir(dir).unwrap();
1783
1784        // ignore v2.0.0 by exact match, and anything starting with "v3" by prefix
1785        let gc = crate::config::GitConfig {
1786            ignore_tags: Some(vec!["v2.0.0".to_string()]),
1787            ignore_tag_prefixes: Some(vec!["v3".to_string()]),
1788            ..Default::default()
1789        };
1790        let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
1791        assert_eq!(result, Some("v1.0.0".to_string()));
1792
1793        std::env::set_current_dir(orig).unwrap();
1794    }
1795
1796    #[test]
1797    #[serial]
1798    fn test_find_latest_tag_with_prefixed_template() {
1799        let tmp = tempfile::tempdir().unwrap();
1800        let dir = tmp.path();
1801        init_repo_with_tags(
1802            dir,
1803            &[
1804                "myapp-v1.0.0",
1805                "myapp-v2.0.0",
1806                "myapp-v3.0.0",
1807                "other-v9.0.0",
1808            ],
1809        );
1810
1811        let orig = std::env::current_dir().unwrap();
1812        std::env::set_current_dir(dir).unwrap();
1813
1814        // Ignore myapp-v3.0.0 specifically
1815        let gc = crate::config::GitConfig {
1816            ignore_tags: Some(vec!["myapp-v3.0.0".to_string()]),
1817            ..Default::default()
1818        };
1819        let result = find_latest_tag_matching("myapp-v{{ .Version }}", Some(&gc), None).unwrap();
1820        assert_eq!(result, Some("myapp-v2.0.0".to_string()));
1821
1822        std::env::set_current_dir(orig).unwrap();
1823    }
1824
1825    #[test]
1826    #[serial]
1827    fn test_find_latest_tag_default_git_config_same_as_none() {
1828        let tmp = tempfile::tempdir().unwrap();
1829        let dir = tmp.path();
1830        init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v2.0.0"]);
1831
1832        let orig = std::env::current_dir().unwrap();
1833        std::env::set_current_dir(dir).unwrap();
1834
1835        // Default GitConfig has all fields None — should behave identically to None
1836        let gc = crate::config::GitConfig::default();
1837        let with_default = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
1838        let with_none = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
1839        assert_eq!(with_default, with_none);
1840        assert_eq!(with_default, Some("v2.0.0".to_string()));
1841
1842        std::env::set_current_dir(orig).unwrap();
1843    }
1844
1845    #[test]
1846    #[serial]
1847    fn test_find_latest_tag_prerelease_suffix_with_default_sort() {
1848        let tmp = tempfile::tempdir().unwrap();
1849        let dir = tmp.path();
1850        // Create tags: two releases and a prerelease with -rc suffix.
1851        // v1.1.1-rc.1 is semantically version 1.1.1 with a prerelease,
1852        // which is > 1.1.0 in both SemVer and git version sort.
1853        // versionsort.suffix only affects ordering relative to the same
1854        // base version (e.g. v1.1.1-rc.1 vs v1.1.1), not across different
1855        // patch levels.
1856        init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v1.1.1-rc.1"]);
1857
1858        let orig = std::env::current_dir().unwrap();
1859        std::env::set_current_dir(dir).unwrap();
1860
1861        // Without prerelease_suffix, using Rust-side SemVer sort:
1862        // v1.1.1-rc.1 is a prerelease of v1.1.1, which is > v1.1.0 but
1863        // SemVer says prereleases are < the release, so 1.1.1-rc.1 < 1.1.1.
1864        // But 1.1.1-rc.1 > 1.1.0 (different patch version), so it wins.
1865        let result_no_suffix = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
1866        assert_eq!(
1867            result_no_suffix,
1868            Some("v1.1.1-rc.1".to_string()),
1869            "without prerelease_suffix, SemVer sort puts v1.1.1-rc.1 highest"
1870        );
1871
1872        // With prerelease_suffix="-rc", git-delegated sort is activated
1873        // (use_git_sort=true). versionsort.suffix=-rc makes -rc tags sort
1874        // after their base version (so v1.1.1-rc.1 comes after v1.1.1),
1875        // but v1.1.1-rc.1 is still version 1.1.1 which is > 1.1.0.
1876        // Since we take the first (highest) from git's descending sort,
1877        // v1.1.1-rc.1 remains the latest.
1878        let gc = crate::config::GitConfig {
1879            prerelease_suffix: Some("-rc".to_string()),
1880            ..Default::default()
1881        };
1882        let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
1883        assert_eq!(
1884            result,
1885            Some("v1.1.1-rc.1".to_string()),
1886            "prerelease_suffix activates git-delegated sort; v1.1.1-rc.1 still highest"
1887        );
1888
1889        // Now test the scenario where versionsort.suffix actually matters:
1890        // when the release version exists alongside the prerelease.
1891        // Add v1.1.1 — without suffix, git sorts rc before release (v1.1.1-rc.1 < v1.1.1);
1892        // with suffix, rc sorts *after* release but --sort=-version:refname
1893        // means descending, so release comes first.
1894        let run = |args: &[&str]| {
1895            let out = std::process::Command::new("git")
1896                .args(args)
1897                .current_dir(dir)
1898                .env("GIT_AUTHOR_NAME", "test")
1899                .env("GIT_AUTHOR_EMAIL", "test@test.com")
1900                .env("GIT_COMMITTER_NAME", "test")
1901                .env("GIT_COMMITTER_EMAIL", "test@test.com")
1902                .output()
1903                .unwrap();
1904            assert!(out.status.success());
1905        };
1906        run(&["tag", "v1.1.1"]);
1907
1908        // With versionsort.suffix=-rc and both v1.1.1 and v1.1.1-rc.1 present,
1909        // the suffix causes -rc.1 to sort after v1.1.1 in ascending order,
1910        // meaning v1.1.1-rc.1 comes last. In descending sort (-version:refname),
1911        // v1.1.1-rc.1 would be first. But the key point is that git-delegated
1912        // sort IS being used (prerelease_suffix triggers it).
1913        let result_both = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
1914        assert!(
1915            result_both.is_some(),
1916            "should find a tag with both release and rc present"
1917        );
1918
1919        std::env::set_current_dir(orig).unwrap();
1920    }
1921
1922    #[test]
1923    #[serial]
1924    fn test_find_latest_tag_ignore_tags_template_rendered() {
1925        let tmp = tempfile::tempdir().unwrap();
1926        let dir = tmp.path();
1927        init_repo_with_tags(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
1928
1929        let orig = std::env::current_dir().unwrap();
1930        std::env::set_current_dir(dir).unwrap();
1931
1932        // Set up template vars with an env variable
1933        let mut vars = crate::template::TemplateVars::new();
1934        vars.set_env("IGNORE_TAG", "v3.0.0");
1935
1936        // Use a template expression in ignore_tags
1937        let gc = crate::config::GitConfig {
1938            ignore_tags: Some(vec!["{{ .Env.IGNORE_TAG }}".to_string()]),
1939            ..Default::default()
1940        };
1941
1942        // Without template_vars, the raw string "{{ .Env.IGNORE_TAG }}" won't
1943        // match any tag, so v3.0.0 is still included.
1944        let result_raw = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
1945        assert_eq!(result_raw, Some("v3.0.0".to_string()));
1946
1947        // With template_vars, the template is rendered to "v3.0.0" which
1948        // matches and excludes that tag.
1949        let result_rendered =
1950            find_latest_tag_matching("v{{ .Version }}", Some(&gc), Some(&vars)).unwrap();
1951        assert_eq!(result_rendered, Some("v2.0.0".to_string()));
1952
1953        std::env::set_current_dir(orig).unwrap();
1954    }
1955
1956    /// Create a git repo in `dir` with separate commits for each tag
1957    /// (needed for `git describe --tags --abbrev=0` to work correctly).
1958    fn init_repo_with_tagged_commits(dir: &std::path::Path, tags: &[&str]) {
1959        use std::process::Command;
1960
1961        let run = |args: &[&str]| {
1962            let out = Command::new("git")
1963                .args(args)
1964                .current_dir(dir)
1965                .env("GIT_AUTHOR_NAME", "test")
1966                .env("GIT_AUTHOR_EMAIL", "test@test.com")
1967                .env("GIT_COMMITTER_NAME", "test")
1968                .env("GIT_COMMITTER_EMAIL", "test@test.com")
1969                .output()
1970                .unwrap();
1971            assert!(
1972                out.status.success(),
1973                "git {:?} failed: {}",
1974                args,
1975                String::from_utf8_lossy(&out.stderr)
1976            );
1977        };
1978
1979        run(&["init"]);
1980        run(&["config", "user.email", "test@test.com"]);
1981        run(&["config", "user.name", "test"]);
1982
1983        for (i, tag) in tags.iter().enumerate() {
1984            let filename = format!("file_{}", i);
1985            std::fs::write(dir.join(&filename), format!("content {}", i)).unwrap();
1986            run(&["add", "."]);
1987            run(&["commit", "-m", &format!("commit for {}", tag)]);
1988            run(&["tag", tag]);
1989        }
1990    }
1991
1992    #[test]
1993    #[serial]
1994    fn test_find_previous_tag_with_ignore_tags() {
1995        let tmp = tempfile::tempdir().unwrap();
1996        let dir = tmp.path();
1997        // Create commits with tags: v1.0.0, v2.0.0, v3.0.0
1998        // Each tag on a separate commit so git describe can find them.
1999        init_repo_with_tagged_commits(dir, &["v1.0.0", "v2.0.0", "v3.0.0"]);
2000
2001        let orig = std::env::current_dir().unwrap();
2002        std::env::set_current_dir(dir).unwrap();
2003
2004        // Without ignore_tags, previous tag of v3.0.0 should be v2.0.0
2005        let result = find_previous_tag("v3.0.0", None, None).unwrap();
2006        assert_eq!(result, Some("v2.0.0".to_string()));
2007
2008        // With v2.0.0 in ignore_tags, it should be excluded via --exclude
2009        // and the previous tag should be v1.0.0
2010        let gc = crate::config::GitConfig {
2011            ignore_tags: Some(vec!["v2.0.0".to_string()]),
2012            ..Default::default()
2013        };
2014        let result_filtered = find_previous_tag("v3.0.0", Some(&gc), None).unwrap();
2015        assert_eq!(result_filtered, Some("v1.0.0".to_string()));
2016
2017        std::env::set_current_dir(orig).unwrap();
2018    }
2019
2020    #[test]
2021    #[serial]
2022    fn test_find_previous_tag_with_ignore_tag_prefixes() {
2023        let tmp = tempfile::tempdir().unwrap();
2024        let dir = tmp.path();
2025        // Create tags where the previous tag has a prefix we want to ignore
2026        init_repo_with_tagged_commits(dir, &["v1.0.0", "nightly-v2.0.0", "v3.0.0"]);
2027
2028        let orig = std::env::current_dir().unwrap();
2029        std::env::set_current_dir(dir).unwrap();
2030
2031        // Without filtering, previous tag of v3.0.0 is nightly-v2.0.0
2032        let result = find_previous_tag("v3.0.0", None, None).unwrap();
2033        assert_eq!(result, Some("nightly-v2.0.0".to_string()));
2034
2035        // With ignore_tag_prefixes=["nightly-"], nightly-v2.0.0 is excluded
2036        // via --exclude=nightly-* and git describe skips it, returning v1.0.0
2037        let gc = crate::config::GitConfig {
2038            ignore_tag_prefixes: Some(vec!["nightly-".to_string()]),
2039            ..Default::default()
2040        };
2041        let result_filtered = find_previous_tag("v3.0.0", Some(&gc), None).unwrap();
2042        assert_eq!(result_filtered, Some("v1.0.0".to_string()));
2043
2044        std::env::set_current_dir(orig).unwrap();
2045    }
2046
2047    #[test]
2048    #[serial]
2049    fn test_find_previous_tag_no_config_unchanged_behavior() {
2050        let tmp = tempfile::tempdir().unwrap();
2051        let dir = tmp.path();
2052        init_repo_with_tagged_commits(dir, &["v1.0.0", "v2.0.0"]);
2053
2054        let orig = std::env::current_dir().unwrap();
2055        std::env::set_current_dir(dir).unwrap();
2056
2057        let result = find_previous_tag("v2.0.0", None, None).unwrap();
2058        assert_eq!(result, Some("v1.0.0".to_string()));
2059
2060        std::env::set_current_dir(orig).unwrap();
2061    }
2062
2063    // -----------------------------------------------------------------------
2064    // strip_monorepo_prefix tests
2065    // -----------------------------------------------------------------------
2066
2067    #[test]
2068    fn test_strip_monorepo_prefix_with_match() {
2069        assert_eq!(
2070            strip_monorepo_prefix("subproject1/v1.2.3", "subproject1/"),
2071            "v1.2.3"
2072        );
2073    }
2074
2075    #[test]
2076    fn test_strip_monorepo_prefix_no_match() {
2077        assert_eq!(strip_monorepo_prefix("v1.2.3", "subproject1/"), "v1.2.3");
2078    }
2079
2080    #[test]
2081    fn test_strip_monorepo_prefix_empty_prefix() {
2082        assert_eq!(strip_monorepo_prefix("v1.2.3", ""), "v1.2.3");
2083    }
2084
2085    #[test]
2086    fn test_strip_monorepo_prefix_partial_match() {
2087        // "sub" is a prefix of "subproject1/" but not the full prefix.
2088        assert_eq!(
2089            strip_monorepo_prefix("subproject1/v1.2.3", "sub"),
2090            "project1/v1.2.3"
2091        );
2092    }
2093
2094    // -----------------------------------------------------------------------
2095    // find_latest_tag_matching_with_prefix (monorepo) tests
2096    // -----------------------------------------------------------------------
2097
2098    #[test]
2099    #[serial]
2100    fn test_find_latest_tag_with_monorepo_prefix_filters_and_returns_full_tag() {
2101        let tmp = tempfile::tempdir().unwrap();
2102        let dir = tmp.path();
2103        init_repo_with_tags(
2104            dir,
2105            &[
2106                "v1.0.0",
2107                "subproject1/v1.0.0",
2108                "subproject1/v2.0.0",
2109                "subproject2/v3.0.0",
2110            ],
2111        );
2112
2113        let orig = std::env::current_dir().unwrap();
2114        std::env::set_current_dir(dir).unwrap();
2115
2116        // With monorepo prefix "subproject1/", should only find subproject1 tags
2117        // and return the FULL tag (with prefix).
2118        let result = find_latest_tag_matching_with_prefix(
2119            "v{{ .Version }}",
2120            None,
2121            None,
2122            Some("subproject1/"),
2123        )
2124        .unwrap();
2125        assert_eq!(
2126            result,
2127            Some("subproject1/v2.0.0".to_string()),
2128            "should return the full tag with prefix"
2129        );
2130
2131        std::env::set_current_dir(orig).unwrap();
2132    }
2133
2134    #[test]
2135    #[serial]
2136    fn test_find_latest_tag_with_monorepo_prefix_semver_comparison_uses_stripped_tag() {
2137        let tmp = tempfile::tempdir().unwrap();
2138        let dir = tmp.path();
2139        // Versions should be compared using the stripped tag
2140        init_repo_with_tags(dir, &["myapp/v1.0.0", "myapp/v2.0.0", "myapp/v1.5.0"]);
2141
2142        let orig = std::env::current_dir().unwrap();
2143        std::env::set_current_dir(dir).unwrap();
2144
2145        let result =
2146            find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("myapp/"))
2147                .unwrap();
2148        assert_eq!(
2149            result,
2150            Some("myapp/v2.0.0".to_string()),
2151            "should pick the highest version based on stripped semver"
2152        );
2153
2154        std::env::set_current_dir(orig).unwrap();
2155    }
2156
2157    #[test]
2158    #[serial]
2159    fn test_find_latest_tag_with_monorepo_prefix_no_matching_tags() {
2160        let tmp = tempfile::tempdir().unwrap();
2161        let dir = tmp.path();
2162        init_repo_with_tags(dir, &["v1.0.0", "v2.0.0"]);
2163
2164        let orig = std::env::current_dir().unwrap();
2165        std::env::set_current_dir(dir).unwrap();
2166
2167        // No tags start with "myapp/" so result should be None.
2168        let result =
2169            find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("myapp/"))
2170                .unwrap();
2171        assert_eq!(result, None);
2172
2173        std::env::set_current_dir(orig).unwrap();
2174    }
2175
2176    #[test]
2177    #[serial]
2178    fn test_find_latest_tag_with_monorepo_prefix_none_behaves_like_original() {
2179        let tmp = tempfile::tempdir().unwrap();
2180        let dir = tmp.path();
2181        init_repo_with_tags(dir, &["v1.0.0", "v1.1.0", "v2.0.0"]);
2182
2183        let orig = std::env::current_dir().unwrap();
2184        std::env::set_current_dir(dir).unwrap();
2185
2186        // Without monorepo prefix, should behave exactly like find_latest_tag_matching.
2187        let result_with_prefix =
2188            find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, None).unwrap();
2189        let result_original = find_latest_tag_matching("v{{ .Version }}", None, None).unwrap();
2190        assert_eq!(result_with_prefix, result_original);
2191        assert_eq!(result_with_prefix, Some("v2.0.0".to_string()));
2192
2193        std::env::set_current_dir(orig).unwrap();
2194    }
2195
2196    #[test]
2197    #[serial]
2198    fn test_find_latest_tag_with_monorepo_prefix_and_prerelease() {
2199        let tmp = tempfile::tempdir().unwrap();
2200        let dir = tmp.path();
2201        init_repo_with_tags(dir, &["svc/v1.0.0", "svc/v1.1.0-rc.1", "svc/v1.1.0"]);
2202
2203        let orig = std::env::current_dir().unwrap();
2204        std::env::set_current_dir(dir).unwrap();
2205
2206        let result =
2207            find_latest_tag_matching_with_prefix("v{{ .Version }}", None, None, Some("svc/"))
2208                .unwrap();
2209        assert_eq!(
2210            result,
2211            Some("svc/v1.1.0".to_string()),
2212            "release v1.1.0 should win over v1.1.0-rc.1"
2213        );
2214
2215        std::env::set_current_dir(orig).unwrap();
2216    }
2217}