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
9pub 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, (None, Some(_)) => std::cmp::Ordering::Greater, (Some(a), Some(b)) => compare_prerelease(a, b),
91 (None, None) => std::cmp::Ordering::Equal,
92 })
93 }
94}
95
96fn 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), (Ok(_), Err(_)) => Ordering::Less, (Err(_), Ok(_)) => Ordering::Greater, (Err(_), Err(_)) => ai.cmp(bi), };
116 if ord != Ordering::Equal {
117 return ord;
118 }
119 }
120 a_ids.len().cmp(&b_ids.len())
122}
123
124static SEMVER_RE: LazyLock<Regex> =
129 LazyLock::new(|| crate::util::static_regex(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(.+))?$"));
130
131pub 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
149pub fn parse_semver_tag(tag: &str) -> Result<SemVer> {
155 if let Ok(sv) = parse_semver(tag) {
157 return Ok(sv);
158 }
159 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 pub commit_date: String,
178 pub commit_timestamp: String,
180 pub previous_tag: Option<String>,
183 pub remote_url: String,
185 pub summary: String,
187 pub tag_subject: String,
189 pub tag_contents: String,
191 pub tag_body: String,
193 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 pub body: String,
207}
208
209fn 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
219pub fn is_git_dirty() -> bool {
221 git_output(&["status", "--porcelain"])
222 .map(|s| !s.is_empty())
223 .unwrap_or(false)
224}
225
226pub fn local_git_user_name() -> Option<String> {
228 git_output(&["config", "user.name"])
229 .ok()
230 .filter(|s| !s.is_empty())
231}
232
233pub fn local_git_user_email() -> Option<String> {
235 git_output(&["config", "user.email"])
236 .ok()
237 .filter(|s| !s.is_empty())
238}
239
240fn 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
254pub fn detect_git_info(tag: &str, skip_validate: bool) -> Result<GitInfo> {
265 if !is_git_repo() {
266 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 let remote_url_raw = git_output(&["ls-remote", "--get-url"]).unwrap_or_default();
306 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 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
378const VERSION_PLACEHOLDERS: &[&str] = &[
380 "{{ .Version }}",
381 "{{.Version}}",
382 "{{ Version }}",
383 "{{Version}}",
384];
385
386pub fn has_version_placeholder(template: &str) -> bool {
388 VERSION_PLACEHOLDERS.iter().any(|p| template.contains(p))
389}
390
391pub 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
404pub fn strip_monorepo_prefix<'a>(tag: &'a str, prefix: &str) -> &'a str {
416 tag.strip_prefix(prefix).unwrap_or(tag)
417}
418
419pub 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
443pub 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 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 let (rendered_ignore_tags, rendered_ignore_prefixes) =
472 render_ignore_patterns(git_config, template_vars);
473
474 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 let use_git_sort = tag_sort == "-version:creatordate" || prerelease_suffix.is_some();
491
492 let tags_output = if use_git_sort {
493 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 .filter(|t| {
514 monorepo_prefix
515 .map(|pfx| t.starts_with(pfx))
516 .unwrap_or(true)
517 })
518 .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 .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 .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 .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 Ok(matching.into_iter().next().map(|(_, tag)| tag))
562 } else {
563 matching.sort_by(|a, b| a.0.cmp(&b.0));
565 Ok(matching.last().map(|(_, tag)| tag.clone()))
566 }
567}
568
569fn 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
599pub 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
611pub 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
632pub 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
643pub 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
663fn 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
701pub 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
715pub 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
732pub 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
765pub 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
788pub 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 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 return Ok(vec![val]);
819 }
820
821 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 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
847fn 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(); 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
880pub 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 let (owner, repo) = detect_github_repo()?;
904
905 let sha = git_output(&["rev-parse", "HEAD"])?;
907
908 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 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
954pub 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
966pub 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
978pub fn get_current_branch() -> Result<String> {
980 git_output(&["rev-parse", "--abbrev-ref", "HEAD"])
981}
982
983pub 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
990pub fn get_short_commit() -> Result<String> {
992 git_output(&["rev-parse", "--short", "HEAD"])
993}
994
995pub 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
1001pub 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
1015pub 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
1029pub 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
1038pub 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 let url = url.strip_suffix(".git").unwrap_or(url);
1048
1049 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 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
1068pub 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
1076pub 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 let url = url.strip_suffix(".git").unwrap_or(url);
1091
1092 if url.starts_with("https://") || url.starts_with("http://") {
1094 let after_scheme = if let Some(rest) = url.strip_prefix("https://") {
1096 rest
1097 } else {
1098 url.strip_prefix("http://")?
1099 };
1100 let after_host = after_scheme.find('/').map(|i| &after_scheme[i + 1..])?;
1102 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 if let Some(colon_pos) = url.find(':') {
1114 let before_colon = &url[..colon_pos];
1115 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
1130pub 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
1140pub 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
1164pub 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 let (rendered_ignore_tags, rendered_ignore_prefixes) =
1179 render_ignore_patterns(git_config, template_vars);
1180
1181 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 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
1212pub fn get_first_commit() -> Result<String> {
1217 let output = git_output(&["rev-list", "--max-parents=0", "HEAD"])?;
1218 output
1220 .lines()
1221 .last()
1222 .map(|s| s.to_string())
1223 .ok_or_else(|| anyhow::anyhow!("no commits found in repository"))
1224}
1225
1226pub 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
1241pub 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
1250pub fn is_git_repo() -> bool {
1252 git_output(&["rev-parse", "--git-dir"]).is_ok()
1253}
1254
1255pub fn git_status_porcelain() -> String {
1257 git_output(&["status", "--porcelain"]).unwrap_or_default()
1258}
1259
1260pub fn is_shallow_clone() -> bool {
1265 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 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 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 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 #[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 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 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 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 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 use serial_test::serial;
1560
1561 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 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 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 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 let gc = crate::config::GitConfig {
1732 ignore_tag_prefixes: Some(vec!["nightly-".to_string()]),
1733 ..Default::default()
1734 };
1735 let result = find_latest_tag_matching("v{{ .Version }}", Some(&gc), None).unwrap();
1738 assert_eq!(result, Some("v2.0.0".to_string()));
1739
1740 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 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 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 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 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 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 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 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 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 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 let mut vars = crate::template::TemplateVars::new();
1934 vars.set_env("IGNORE_TAG", "v3.0.0");
1935
1936 let gc = crate::config::GitConfig {
1938 ignore_tags: Some(vec!["{{ .Env.IGNORE_TAG }}".to_string()]),
1939 ..Default::default()
1940 };
1941
1942 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 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 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 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 let result = find_previous_tag("v3.0.0", None, None).unwrap();
2006 assert_eq!(result, Some("v2.0.0".to_string()));
2007
2008 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 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 let result = find_previous_tag("v3.0.0", None, None).unwrap();
2033 assert_eq!(result, Some("nightly-v2.0.0".to_string()));
2034
2035 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 #[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 assert_eq!(
2089 strip_monorepo_prefix("subproject1/v1.2.3", "sub"),
2090 "project1/v1.2.3"
2091 );
2092 }
2093
2094 #[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 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 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 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 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}