1use crate::config::{
8 effective::EffectiveConfiguration, CommitMessageIncrementMode, DeploymentMode,
9 GitVersionConfiguration, IncrementStrategy, SemanticVersionFormat, VersionStrategy,
10 VersioningScheme,
11};
12use crate::git::{CommitInfo, GitRepo};
13use crate::output::variables::VersionVariables;
14use crate::version::{BuildMetaData, PreReleaseTag, SemanticVersion, VersionField};
15use anyhow::Result;
16use chrono::{DateTime, FixedOffset, NaiveDateTime, TimeZone};
17use regex::Regex;
18use std::collections::HashSet;
19
20#[derive(Debug, Clone, Default)]
22struct IgnoreSet {
23 shas: HashSet<String>,
24 before: Option<DateTime<FixedOffset>>,
25 paths: Vec<String>,
27}
28
29impl IgnoreSet {
30 fn from_config(config: &GitVersionConfiguration) -> Self {
31 let shas: HashSet<String> = config.ignore.sha.iter().map(|s| s.to_lowercase()).collect();
32 let before = config
33 .ignore
34 .commits_before
35 .as_deref()
36 .and_then(parse_ignore_date);
37 let paths = config.ignore.paths.clone();
38 IgnoreSet {
39 shas,
40 before,
41 paths,
42 }
43 }
44
45 fn is_ignored(&self, sha: &str, when: &DateTime<FixedOffset>) -> bool {
46 if self.shas.contains(&sha.to_lowercase()) {
47 return true;
48 }
49 if self
51 .shas
52 .iter()
53 .any(|s| sha.to_lowercase().starts_with(s.as_str()) && s.len() >= 7)
54 {
55 return true;
56 }
57 matches!(&self.before, Some(b) if when < b)
58 }
59
60 fn is_path_ignored(&self, repo: &crate::git::GitRepo, sha: &str) -> bool {
62 if self.paths.is_empty() {
63 return false;
64 }
65 let changed = repo.changed_paths_for_commit(sha);
66 if changed.is_empty() {
69 return true;
70 }
71 changed.iter().all(|file| {
72 self.paths.iter().any(|prefix| {
73 let prefix = prefix.trim_end_matches('/');
74 file == prefix || file.starts_with(&format!("{prefix}/"))
75 })
76 })
77 }
78
79 fn filter(&self, repo: &crate::git::GitRepo, commits: Vec<CommitInfo>) -> Vec<CommitInfo> {
80 if self.shas.is_empty() && self.before.is_none() && self.paths.is_empty() {
81 return commits;
82 }
83 commits
84 .into_iter()
85 .filter(|c| !self.is_ignored(&c.sha, &c.when) && !self.is_path_ignored(repo, &c.sha))
86 .collect()
87 }
88}
89
90fn parse_ignore_date(s: &str) -> Option<DateTime<FixedOffset>> {
92 let s = s.trim();
93 for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] {
94 if let Ok(ndt) = NaiveDateTime::parse_from_str(s, fmt) {
95 return Some(chrono::Utc.from_utc_datetime(&ndt).fixed_offset());
96 }
97 if let Ok(d) = chrono::NaiveDate::parse_from_str(s, fmt) {
98 if let Some(ndt) = d.and_hms_opt(0, 0, 0) {
99 return Some(chrono::Utc.from_utc_datetime(&ndt).fixed_offset());
100 }
101 }
102 }
103 None
104}
105
106fn dotnet_date_format_to_strftime(fmt: &str) -> String {
108 let mut out = fmt.to_string();
110 for (from, to) in [
111 ("yyyy", "%Y"),
112 ("yy", "%y"),
113 ("MMMM", "%B"),
114 ("MMM", "%b"),
115 ("MM", "%m"),
116 ("dddd", "%A"),
117 ("ddd", "%a"),
118 ("dd", "%d"),
119 ("HH", "%H"),
120 ("mm", "%M"),
121 ("ss", "%S"),
122 ] {
123 out = out.replace(from, to);
124 }
125 out
126}
127
128#[derive(Debug, Clone)]
130struct BaseVersion {
131 source: String,
132 semantic_version: SemanticVersion,
133 base_version_source: Option<String>,
134 source_when: Option<DateTime<FixedOffset>>,
136 increment: VersionField,
137 label: Option<String>,
138 force_increment: bool,
139 exact: bool,
141}
142
143impl BaseVersion {
144 fn new(
145 source: impl Into<String>,
146 semantic_version: SemanticVersion,
147 base_version_source: Option<String>,
148 increment: VersionField,
149 label: Option<String>,
150 ) -> Self {
151 Self {
152 source: source.into(),
153 semantic_version,
154 base_version_source,
155 source_when: None,
156 increment,
157 label,
158 force_increment: false,
159 exact: false,
160 }
161 }
162}
163
164#[derive(Debug, Clone)]
166struct NextVersion {
167 incremented: SemanticVersion,
168 base: BaseVersion,
169}
170
171fn strategy_to_field(s: IncrementStrategy) -> VersionField {
173 match s {
174 IncrementStrategy::Major => VersionField::Major,
175 IncrementStrategy::Minor => VersionField::Minor,
176 IncrementStrategy::Patch => VersionField::Patch,
177 IncrementStrategy::None | IncrementStrategy::Inherit => VersionField::None,
178 }
179}
180
181fn increment_from_message(msg: &str, eff: &EffectiveConfiguration) -> Option<VersionField> {
183 let test = |pat: &str| {
184 Regex::new(&format!("(?im){pat}"))
185 .map(|r| r.is_match(msg))
186 .unwrap_or(false)
187 };
188 if test(&eff.major_bump_message) {
189 Some(VersionField::Major)
190 } else if test(&eff.minor_bump_message) {
191 Some(VersionField::Minor)
192 } else if test(&eff.patch_bump_message) {
193 Some(VersionField::Patch)
194 } else if test(&eff.no_bump_message) {
195 Some(VersionField::None)
196 } else {
197 None
198 }
199}
200
201fn determine_increment(
204 repo: &GitRepo,
205 base_source: Option<&str>,
206 head_sha: &str,
207 should_increment: bool,
208 eff: &EffectiveConfiguration,
209 ignore: &IgnoreSet,
210) -> Result<VersionField> {
211 let default_increment = strategy_to_field(eff.increment);
212
213 let message_increment =
214 if eff.commit_message_incrementing == CommitMessageIncrementMode::Disabled {
215 None
216 } else {
217 let commits = ignore.filter(repo, repo.commits_between(base_source, head_sha)?);
218 let merge_only =
219 eff.commit_message_incrementing == CommitMessageIncrementMode::MergeMessageOnly;
220 let mut best: Option<VersionField> = None;
221 for c in &commits {
222 if merge_only && c.parent_count < 2 {
223 continue;
224 }
225 if let Some(f) = increment_from_message(&c.message, eff) {
226 best = Some(best.map_or(f, |b| b.max(f)));
227 }
228 }
229 best
230 };
231
232 Ok(match message_increment {
233 None => {
234 if should_increment {
235 default_increment
236 } else {
237 VersionField::None
238 }
239 }
240 Some(mi) => {
241 if should_increment && mi < default_increment {
242 default_increment
243 } else {
244 mi
245 }
246 }
247 })
248}
249
250fn parse_version(input: &str, eff: &EffectiveConfiguration) -> Option<SemanticVersion> {
252 let strict = eff.semantic_version_format == SemanticVersionFormat::Strict;
253 SemanticVersion::parse_with(input, &eff.tag_prefix, strict)
254}
255
256fn validate_config_regexes(eff: &EffectiveConfiguration) -> Result<()> {
261 let check = |label: &str, pat: &str| -> Result<()> {
262 Regex::new(pat)
263 .map(|_| ())
264 .map_err(|e| anyhow::anyhow!("Invalid {label} regex '{pat}': {e}"))
265 };
266 check("tag-prefix", &eff.tag_prefix)?;
267 if eff.commit_message_incrementing != CommitMessageIncrementMode::Disabled {
268 check("major-version-bump-message", &eff.major_bump_message)?;
269 check("minor-version-bump-message", &eff.minor_bump_message)?;
270 check("patch-version-bump-message", &eff.patch_bump_message)?;
271 check("no-bump-message", &eff.no_bump_message)?;
272 }
273 Ok(())
274}
275
276fn extract_version(text: &str, eff: &EffectiveConfiguration) -> Option<SemanticVersion> {
282 let pattern = format!(
283 "(?i)^{}",
284 eff.version_in_branch_pattern.trim_start_matches('^')
285 );
286 let re = Regex::new(&pattern).ok()?;
287 let sep = if text.contains('/') || !text.contains('-') {
288 '/'
289 } else {
290 '-'
291 };
292 for part in text.split(sep) {
293 if part.is_empty() {
294 continue;
295 }
296 if let Some(caps) = re.captures(part) {
297 let raw = caps
298 .name("version")
299 .map(|m| m.as_str())
300 .unwrap_or_else(|| caps.get(0).unwrap().as_str());
301 if let Some(v) = parse_version(raw, eff) {
302 return Some(v);
303 }
304 }
305 }
306 None
307}
308
309fn resolve_inherit_via_git(
313 repo: &GitRepo,
314 config: &GitVersionConfiguration,
315 branch_name: &str,
316) -> Result<Option<IncrementStrategy>> {
317 let Some((_, bc)) = crate::config::effective::find_branch_config(config, branch_name) else {
318 return Ok(None);
319 };
320 let own = bc
321 .increment
322 .or(config.increment)
323 .unwrap_or(IncrementStrategy::Inherit);
324 if own != IncrementStrategy::Inherit {
325 return Ok(None);
326 }
327
328 let repo_branches = repo.branch_names().unwrap_or_default();
329 let mut best: Option<(i64, IncrementStrategy)> = None;
330
331 for src_key in &bc.source_branches {
332 let Some(src_bc) = config.branches.get(src_key) else {
333 continue;
334 };
335 let Some(re_src) = &src_bc.regex else {
336 continue;
337 };
338 let Ok(re) = Regex::new(&format!("(?i){re_src}")) else {
339 continue;
340 };
341
342 for rb in &repo_branches {
344 if rb == branch_name {
345 continue;
346 }
347 let short = rb.rsplit('/').next().unwrap_or(rb);
348 if !(re.is_match(rb) || re.is_match(short)) {
349 continue;
350 }
351 let Some(mb) = repo.merge_base(branch_name, rb)? else {
352 continue;
353 };
354 let depth = repo.commits_between(None, &mb)?.len() as i64;
356 let inc = crate::config::effective::resolve_increment(config, src_bc, 0);
360 if best.map(|(d, _)| depth > d).unwrap_or(true) {
361 best = Some((depth, inc));
362 }
363 }
364 }
365 Ok(best.map(|(_, inc)| inc))
366}
367
368const BUILTIN_MERGE_FORMATS: &[&str] = &[
371 r"^Merge (branch|tag) '(?<SourceBranch>[^']*)'(?: into (?<TargetBranch>[^\s]*))*",
373 r"^Finish (?<SourceBranch>[^\s]*)(?: into (?<TargetBranch>[^\s]*))*",
375 r"^Merge pull request #(?<PullRequestNumber>\d+) (from|in) (?<Source>.*) from (?<SourceBranch>[^\s]*) to (?<TargetBranch>[^\s]*)",
377 r"^Pull request #(?<PullRequestNumber>\d+)[^\r\n]*\r?\n\r?\nMerge in (?<Source>[^\r\n]*) from (?<SourceBranch>[^\s]*) to (?<TargetBranch>[^\s]*)",
380 r"^Merged in (?<SourceBranch>[^\s]*) \(pull request #(?<PullRequestNumber>\d+)\)",
382 r"^Merge pull request #(?<PullRequestNumber>\d+) (from|in) (?:[^\s/]+/)?(?<SourceBranch>[^\s]*)(?: into (?<TargetBranch>[^\s]*))*",
384 r"^Merge remote-tracking branch '(?<SourceBranch>[^\s]*)'(?: into (?<TargetBranch>[^\s]*))*",
386 r"^Merge pull request (?<PullRequestNumber>\d+) from (?<SourceBranch>[^\s]*) into (?<TargetBranch>[^\s]*)",
388];
389
390fn parse_merge_message(
393 message: &str,
394 eff: &EffectiveConfiguration,
395) -> Option<(String, SemanticVersion)> {
396 let from_branch = |sb: &str| -> Option<SemanticVersion> {
397 parse_version(sb, eff).or_else(|| extract_version(sb, eff))
398 };
399
400 let custom = eff.merge_message_formats.values().map(|s| s.as_str());
402 for pattern in custom.chain(BUILTIN_MERGE_FORMATS.iter().copied()) {
403 let Ok(re) = Regex::new(&format!("(?s){pattern}")) else {
404 continue;
405 };
406 let Some(caps) = re.captures(message) else {
407 continue;
408 };
409 let Some(sb) = caps.name("SourceBranch") else {
410 continue;
411 };
412 let branch = sb.as_str().to_string();
413 if let Some(v) = caps
414 .name("Version")
415 .and_then(|m| parse_version(m.as_str(), eff))
416 {
417 return Some((branch, v));
418 }
419 if let Some(v) = from_branch(&branch) {
420 return Some((branch, v));
421 }
422 return None; }
424 None
425}
426
427fn merge_branch_increment(config: &GitVersionConfiguration, message: &str) -> Option<VersionField> {
434 for pattern in BUILTIN_MERGE_FORMATS {
435 let Ok(re) = Regex::new(&format!("(?s){pattern}")) else {
436 continue;
437 };
438 let Some(caps) = re.captures(message) else {
439 continue;
440 };
441 let Some(sb) = caps.name("SourceBranch") else {
442 continue;
443 };
444 let branch = sb.as_str();
445 let (_, bc) = crate::config::effective::find_branch_config(config, branch)?;
446 if bc
449 .prevent_increment
450 .as_ref()
451 .and_then(|pi| pi.when_branch_merged)
452 .unwrap_or(false)
453 {
454 return Some(VersionField::None);
455 }
456 let increment = bc.increment.unwrap_or(IncrementStrategy::Inherit);
457 if matches!(
458 increment,
459 IncrementStrategy::Inherit | IncrementStrategy::None
460 ) {
461 return None;
462 }
463 return Some(strategy_to_field(increment));
464 }
465 None
466}
467
468fn is_release_branch(config: &GitVersionConfiguration, branch_name: &str) -> bool {
470 let short = branch_name.rsplit('/').next().unwrap_or(branch_name);
471 config.branches.values().any(|bc| {
472 bc.is_release_branch == Some(true)
473 && bc
474 .regex
475 .as_ref()
476 .and_then(|r| Regex::new(&format!("(?i){r}")).ok())
477 .map(|re| re.is_match(branch_name) || re.is_match(short))
478 .unwrap_or(false)
479 })
480}
481
482pub fn calculate(
484 repo: &GitRepo,
485 config: &GitVersionConfiguration,
486 branch_override: Option<String>,
487) -> Result<VersionVariables> {
488 let (head, branch_name) = match &branch_override {
491 Some(b) => {
492 let head = repo
493 .commit_info_of(b)
494 .map(Ok)
495 .unwrap_or_else(|| repo.head_commit())?;
496 (head, b.clone())
497 }
498 None => (repo.head_commit()?, repo.current_branch_name()?),
499 };
500 let mut eff = EffectiveConfiguration::resolve(config, &branch_name);
501 validate_config_regexes(&eff)?;
503 let ignore = IgnoreSet::from_config(config);
504
505 if config.strategies.contains(&VersionStrategy::Mainline) {
507 return mainline_calculate(repo, config, &eff, &branch_name, &head, &ignore);
508 }
509
510 if let Some(inc) = resolve_inherit_via_git(repo, config, &branch_name)? {
513 eff.increment = inc;
514 }
515
516 let mut candidates: Vec<BaseVersion> = Vec::new();
517 let mut tag_alternatives: Vec<SemanticVersion> = Vec::new();
518 let strategies = if config.strategies.is_empty() {
519 vec![
520 VersionStrategy::Fallback,
521 VersionStrategy::ConfiguredNextVersion,
522 VersionStrategy::MergeMessage,
523 VersionStrategy::TaggedCommit,
524 VersionStrategy::VersionInBranchName,
525 ]
526 } else {
527 config.strategies.clone()
528 };
529
530 for strat in &strategies {
531 match strat {
532 VersionStrategy::ConfiguredNextVersion => {
533 if let Some(nv) = &eff.next_version {
537 if !nv.is_empty() {
538 let v = parse_version(nv, &eff).ok_or_else(|| {
539 anyhow::anyhow!("Failed to parse {nv} into a Semantic Version")
540 })?;
541 let label_ok =
544 !v.pre_release_tag.has_tag() || v.pre_release_tag.name == eff.label;
545 if label_ok {
546 candidates.push(BaseVersion::new(
547 "ConfiguredNextVersion",
548 v,
549 None,
550 VersionField::None,
551 Some(eff.label.clone()),
552 ));
553 }
554 }
555 }
556 }
557 VersionStrategy::TaggedCommit | VersionStrategy::Mainline => {
558 gather_tagged(
559 repo,
560 &eff,
561 &head,
562 &ignore,
563 &mut candidates,
564 &mut tag_alternatives,
565 )?;
566 }
567 VersionStrategy::VersionInBranchName => {
568 if eff.is_release_branch {
569 if let Some(v) = extract_version(&branch_name, &eff) {
570 candidates.push(BaseVersion::new(
571 "VersionInBranchName",
572 v,
573 None,
574 VersionField::None,
575 Some(eff.label.clone()),
576 ));
577 }
578 }
579 }
580 VersionStrategy::MergeMessage => {
581 if eff.track_merge_message {
583 gather_merge_messages(repo, config, &eff, &head, &ignore, &mut candidates)?;
584 }
585 }
586 VersionStrategy::TrackReleaseBranches => {
587 gather_track_release(repo, config, &eff, &head, &branch_name, &mut candidates)?;
588 }
589 VersionStrategy::Fallback => {
590 let field = determine_increment(repo, None, &head.sha, true, &eff, &ignore)?;
591 candidates.push(BaseVersion::new(
592 "Fallback (0.0.0)",
593 SemanticVersion::new(0, 0, 0),
594 None,
595 field,
596 Some(eff.label.clone()),
597 ));
598 }
599 VersionStrategy::None => {}
600 }
601 }
602
603 if candidates.is_empty() {
604 return Err(anyhow::anyhow!(
609 "No base versions determined on the current branch."
610 ));
611 }
612
613 let next: Vec<NextVersion> = candidates
615 .into_iter()
616 .map(|b| {
617 let incremented = if b.exact {
618 b.semantic_version.clone()
619 } else {
620 b.semantic_version
621 .increment(b.increment, b.label.as_deref(), b.force_increment)
622 };
623 NextVersion {
624 incremented,
625 base: b,
626 }
627 })
628 .collect();
629
630 let max_idx = next.iter().enumerate().fold(0usize, |acc, (i, n)| {
633 if n.incremented.cmp(&next[acc].incremented) == std::cmp::Ordering::Greater {
634 i
635 } else {
636 acc
637 }
638 });
639
640 let latest_source = next
644 .iter()
645 .filter(|n| n.base.base_version_source.is_some())
646 .max_by(|a, b| a.base.source_when.cmp(&b.base.source_when));
647 let base_source = latest_source
648 .and_then(|n| n.base.base_version_source.clone())
649 .or_else(|| next[max_idx].base.base_version_source.clone());
650
651 let chosen = next.into_iter().nth(max_idx).unwrap();
652 let source_semver = chosen.base.semantic_version.clone();
654
655 let mut final_semver = apply_deployment_mode(
656 repo,
657 &eff,
658 &branch_name,
659 &head,
660 &chosen,
661 base_source.as_deref(),
662 &ignore,
663 )?;
664 if let Some(alt) = tag_alternatives.iter().max_by(|a, b| a.cmp_core(b)) {
668 if alt.cmp_core(&final_semver) == std::cmp::Ordering::Greater {
669 final_semver.major = alt.major;
670 final_semver.minor = alt.minor;
671 final_semver.patch = alt.patch;
672 }
673 }
674 let variables = build_variables(&eff, &branch_name, &head, &final_semver, &source_semver)?;
675 Ok(variables)
676}
677
678fn mainline_calculate(
685 repo: &GitRepo,
686 config: &GitVersionConfiguration,
687 eff: &EffectiveConfiguration,
688 branch_name: &str,
689 head: &CommitInfo,
690 ignore: &IgnoreSet,
691) -> Result<VersionVariables> {
692 let mut tags_by_sha: std::collections::HashMap<String, SemanticVersion> =
694 std::collections::HashMap::new();
695 for tag in repo.tags()? {
696 if ignore.is_ignored(&tag.target_sha, &tag.when) {
697 continue;
698 }
699 if let Some(v) = parse_version(&tag.name, eff) {
700 let core = SemanticVersion::new(v.major, v.minor, v.patch);
701 let e = tags_by_sha
702 .entry(tag.target_sha.clone())
703 .or_insert_with(|| core.clone());
704 if core.cmp_core(e) == std::cmp::Ordering::Greater {
705 *e = core;
706 }
707 }
708 }
709 let core_gt =
710 |a: &SemanticVersion, b: &SemanticVersion| a.cmp_core(b) == std::cmp::Ordering::Greater;
711
712 let default = strategy_to_field(eff.increment);
713
714 let merge_base_sha: Option<String> = if !eff.is_main_branch && !eff.source_branches.is_empty() {
718 let src = &eff.source_branches[0];
719 if let Some(src_info) = repo.commit_info_of(src) {
720 repo.merge_base(&head.sha, &src_info.sha)?
721 } else {
722 None
723 }
724 } else {
725 None
726 };
727
728 let trunk_target = merge_base_sha.as_deref().unwrap_or(&head.sha);
731 let trunk_eff_buf;
732 let trunk_eff: &EffectiveConfiguration = if merge_base_sha.is_some() {
733 trunk_eff_buf = EffectiveConfiguration::resolve(config, &eff.source_branches[0]);
734 &trunk_eff_buf
735 } else {
736 eff
737 };
738 let trunk_default = strategy_to_field(trunk_eff.increment);
739
740 let mut trunk = ignore.filter(repo, repo.first_parent_between(None, trunk_target)?);
741 trunk.reverse();
742
743 let mut version = SemanticVersion::new(0, 0, 0);
744 let mut highest_tag = SemanticVersion::new(0, 0, 0);
745 let mut prev_trunk_version = SemanticVersion::new(0, 0, 0);
747 for c in &trunk {
748 prev_trunk_version = version.clone();
749 let introduced: Vec<CommitInfo> = if c.parents.len() >= 2 {
751 ignore.filter(
752 repo,
753 repo.commits_between(Some(&c.parents[0]), &c.parents[1])?,
754 )
755 } else {
756 vec![c.clone()]
757 };
758
759 let mut step_tag: Option<SemanticVersion> = None;
761 for sha in introduced
762 .iter()
763 .map(|x| &x.sha)
764 .chain(std::iter::once(&c.sha))
765 {
766 if let Some(tv) = tags_by_sha.get(sha) {
767 if step_tag.as_ref().map(|s| core_gt(tv, s)).unwrap_or(true) {
768 step_tag = Some(tv.clone());
769 }
770 }
771 }
772
773 if let Some(tv) = step_tag {
774 if core_gt(&tv, &highest_tag) {
775 highest_tag = tv.clone();
776 }
777 if !core_gt(&version, &tv) {
779 version = tv;
780 continue;
781 }
782 }
783
784 let mut field = trunk_default;
786 for ic in &introduced {
787 if let Some(f) = increment_from_message(&ic.message, trunk_eff) {
788 if f > field {
789 field = f;
790 }
791 }
792 }
793 if c.parents.len() >= 2 {
797 match merge_branch_increment(config, &c.message) {
798 Some(VersionField::None) => {
799 field = VersionField::None;
800 }
801 Some(branch_field) if branch_field > field => {
802 field = branch_field;
803 }
804 _ => {}
805 }
806 }
807 version = version.increment(field, None, true);
808 }
809 let trunk_version_end = version.clone();
811
812 let (mut version, source_sha, distance) = if let Some(ref mb_sha) = merge_base_sha {
814 let feature_commits = ignore.filter(repo, repo.commits_between(Some(mb_sha), &head.sha)?);
816 let head_is_tagged = tags_by_sha.contains_key(&head.sha);
817
818 let feature_tag = feature_commits
820 .iter()
821 .filter_map(|c| {
822 tags_by_sha
823 .get(&c.sha)
824 .map(|tv| (c.sha.clone(), tv.clone()))
825 })
826 .reduce(|(sa, a), (sb, b)| if core_gt(&b, &a) { (sb, b) } else { (sa, a) });
827
828 if let Some((ft_sha, ft)) = feature_tag {
829 if head_is_tagged && !eff.prevent_increment_when_current_commit_tagged {
831 let v = ft.increment(default, None, true);
833 (v, Some(head.sha.clone()), 0i64)
834 } else {
835 let d = repo.commits_between(Some(&ft_sha), &head.sha)?.len() as i64;
836 (ft, Some(ft_sha), d)
837 }
838 } else {
839 let v = version.increment(default, None, true);
841 let d = feature_commits.len() as i64;
842 (v, Some(mb_sha.clone()), d)
843 }
844 } else {
845 let head_is_tagged = tags_by_sha.contains_key(&head.sha);
847 if head_is_tagged && !eff.prevent_increment_when_current_commit_tagged {
848 let v = version.increment(default, None, true);
849 (v, Some(head.sha.clone()), 0i64)
850 } else {
851 let s = head.parents.first().cloned();
853 let d = repo.commits_between(s.as_deref(), &head.sha)?.len() as i64;
854 (version, s, d)
855 }
856 };
857
858 let label = eff.label.as_str();
860 let mut commits_since_tag = None;
861 version.pre_release_tag = match eff.deployment_mode {
862 DeploymentMode::ContinuousDeployment => PreReleaseTag::default(),
864 DeploymentMode::ContinuousDelivery => {
866 PreReleaseTag::new(label, Some(distance), label.is_empty())
867 }
868 DeploymentMode::ManualDeployment => {
870 commits_since_tag = Some(distance);
871 PreReleaseTag::new(label, Some(1), label.is_empty())
872 }
873 };
874 version.build_metadata = BuildMetaData {
875 commits_since_tag,
876 branch: Some(branch_name.to_string()),
877 sha: Some(head.sha.clone()),
878 short_sha: Some(head.short_sha.clone()),
879 commit_date: Some(head.when),
880 version_source_sha: source_sha,
881 version_source_distance: distance,
882 uncommitted_changes: repo.uncommitted_changes().unwrap_or(0),
883 version_source_increment: VersionField::None,
884 other_metadata: None,
885 };
886
887 let version_at_source = if merge_base_sha.is_some() {
893 trunk_version_end
894 } else {
895 prev_trunk_version.clone()
896 };
897 let source_semver = match version.build_metadata.version_source_sha.as_deref() {
898 None => SemanticVersion::new(0, 0, 0),
899 Some(sha) => {
900 if let Some(tv) = tags_by_sha.get(sha) {
901 tv.clone()
902 } else {
903 let mut sv = version_at_source;
904 sv.pre_release_tag = PreReleaseTag::new("", Some(1), true);
905 sv
906 }
907 }
908 };
909
910 build_variables(eff, branch_name, head, &version, &source_semver)
911}
912
913fn is_match_for_branch_label(version: &SemanticVersion, label: &str) -> bool {
918 let pre = &version.pre_release_tag;
919 if pre.name.is_empty() && pre.number.is_none() {
921 return true;
922 }
923 pre.has_tag() && pre.name == label
925}
926
927fn gather_tagged(
932 repo: &GitRepo,
933 eff: &EffectiveConfiguration,
934 head: &CommitInfo,
935 ignore: &IgnoreSet,
936 out: &mut Vec<BaseVersion>,
937 alternatives: &mut Vec<SemanticVersion>,
938) -> Result<()> {
939 for tag in repo.tags()? {
940 if ignore.is_ignored(&tag.target_sha, &tag.when) {
941 continue;
942 }
943 if ignore.is_path_ignored(repo, &tag.target_sha) {
944 continue;
945 }
946 if !repo
947 .is_ancestor_of(&tag.target_sha, &head.sha)
948 .unwrap_or(false)
949 {
950 continue;
951 }
952 let Some(version) = parse_version(&tag.name, eff) else {
953 continue;
954 };
955 alternatives.push(version.clone());
957 if !is_match_for_branch_label(&version, &eff.label) {
959 continue;
960 }
961 let is_current = tag.target_sha == head.sha;
962 let exact = is_current && eff.prevent_increment_when_current_commit_tagged;
963 let has_pre = version.pre_release_tag.has_tag();
969 let is_numeric_only_pre = has_pre && version.pre_release_tag.name.is_empty();
970 let use_as_source = exact || !has_pre || is_numeric_only_pre;
971 let base_src = if use_as_source {
972 Some(tag.target_sha.clone())
973 } else {
974 None
975 };
976 let field = if exact {
977 VersionField::None
978 } else {
979 let from = if use_as_source {
980 Some(tag.target_sha.as_str())
981 } else {
982 None
983 };
984 determine_increment(repo, from, &head.sha, true, eff, ignore)?
985 };
986 let mut bv = BaseVersion::new(
987 format!("Tag {}", tag.name),
988 version,
989 base_src,
990 field,
991 Some(eff.label.clone()),
992 );
993 bv.exact = exact;
994 bv.source_when = if use_as_source { Some(tag.when) } else { None };
995 out.push(bv);
996 }
997 Ok(())
998}
999
1000fn gather_merge_messages(
1005 repo: &GitRepo,
1006 config: &GitVersionConfiguration,
1007 eff: &EffectiveConfiguration,
1008 head: &CommitInfo,
1009 ignore: &IgnoreSet,
1010 out: &mut Vec<BaseVersion>,
1011) -> Result<()> {
1012 let mut count = 0usize;
1014 for c in ignore.filter(repo, repo.commits_between(None, &head.sha)?) {
1015 if count >= 5 {
1016 break;
1017 }
1018 let Some((merged_branch, v)) = parse_merge_message(&c.message, eff) else {
1019 continue;
1020 };
1021 if !is_release_branch(config, &merged_branch) {
1023 continue;
1024 }
1025 let base_src = if c.parents.len() >= 2 {
1028 repo.merge_base(&c.parents[0], &c.parents[1])?
1029 .unwrap_or_else(|| c.sha.clone())
1030 } else {
1031 c.sha.clone()
1032 };
1033 let field = if eff.prevent_increment_of_merged_branch {
1034 VersionField::None
1035 } else {
1036 determine_increment(repo, Some(&base_src), &head.sha, true, eff, ignore)?
1037 };
1038 let mut bv = BaseVersion::new(
1039 "MergeMessage",
1040 v,
1041 Some(base_src),
1042 field,
1043 Some(eff.label.clone()),
1044 );
1045 bv.source_when = Some(c.when);
1046 out.push(bv);
1047 count += 1;
1048 }
1049 Ok(())
1050}
1051
1052fn gather_track_release(
1054 repo: &GitRepo,
1055 config: &GitVersionConfiguration,
1056 eff: &EffectiveConfiguration,
1057 head: &CommitInfo,
1058 branch_name: &str,
1059 out: &mut Vec<BaseVersion>,
1060) -> Result<()> {
1061 if !eff.tracks_release_branches {
1062 return Ok(());
1063 }
1064 let Some((_, release_bc)) = config
1065 .branches
1066 .iter()
1067 .find(|(k, _)| k.as_str() == "release")
1068 else {
1069 return Ok(());
1070 };
1071 let Some(re_src) = &release_bc.regex else {
1072 return Ok(());
1073 };
1074 let Ok(re) = Regex::new(&format!("(?i){re_src}")) else {
1075 return Ok(());
1076 };
1077
1078 for rb in repo.branch_names()? {
1079 let short = rb.rsplit('/').next().unwrap_or(&rb);
1080 if !(re.is_match(&rb) || re.is_match(short)) {
1081 continue;
1082 }
1083 if let Some(v) = extract_version(&rb, eff) {
1084 let base_src = repo.merge_base(branch_name, &rb)?;
1085 out.push(BaseVersion::new(
1086 format!("TrackReleaseBranches: {rb}"),
1087 v,
1088 base_src.or(Some(head.sha.clone())),
1089 strategy_to_field(eff.increment),
1090 Some(eff.label.clone()),
1091 ));
1092 }
1093 }
1094 Ok(())
1095}
1096
1097fn apply_deployment_mode(
1099 repo: &GitRepo,
1100 eff: &EffectiveConfiguration,
1101 branch_name: &str,
1102 head: &CommitInfo,
1103 chosen: &NextVersion,
1104 base_source: Option<&str>,
1105 ignore: &IgnoreSet,
1106) -> Result<SemanticVersion> {
1107 let base_src = if chosen.base.exact {
1108 chosen.base.base_version_source.as_deref()
1109 } else {
1110 base_source
1111 };
1112 let commits = ignore
1113 .filter(repo, repo.commits_between(base_src, &head.sha)?)
1114 .len() as i64;
1115 let uncommitted = repo.uncommitted_changes().unwrap_or(0);
1116
1117 let mut sv = chosen.incremented.clone();
1118 let mut meta = BuildMetaData {
1119 commits_since_tag: Some(commits),
1120 branch: Some(branch_name.to_string()),
1121 sha: Some(head.sha.clone()),
1122 short_sha: Some(head.short_sha.clone()),
1123 commit_date: Some(head.when),
1124 version_source_sha: base_src.map(|s| s.to_string()),
1125 version_source_distance: commits,
1126 uncommitted_changes: uncommitted,
1127 version_source_increment: VersionField::None,
1130 other_metadata: None,
1131 };
1132
1133 if chosen.base.exact {
1134 meta.commits_since_tag = None;
1136 sv.build_metadata = meta;
1137 return Ok(sv);
1138 }
1139
1140 match eff.deployment_mode {
1141 DeploymentMode::ManualDeployment => {
1142 }
1144 DeploymentMode::ContinuousDelivery => {
1145 if sv.pre_release_tag.has_tag() {
1146 let n = sv.pre_release_tag.number.unwrap_or(1);
1147 sv.pre_release_tag.number = Some(n + commits - 1);
1148 }
1149 meta.commits_since_tag = None;
1150 }
1151 DeploymentMode::ContinuousDeployment => {
1152 sv.pre_release_tag = PreReleaseTag::default();
1153 meta.commits_since_tag = None;
1154 }
1155 }
1156
1157 sv.build_metadata = meta;
1158 Ok(sv)
1159}
1160
1161fn build_variables(
1163 eff: &EffectiveConfiguration,
1164 branch_name: &str,
1165 head: &CommitInfo,
1166 sv: &SemanticVersion,
1167 source_semver: &SemanticVersion,
1168) -> Result<VersionVariables> {
1169 let pre = &sv.pre_release_tag;
1170 let pre_label = pre.name.clone();
1171 let pre_number = pre.number;
1172 let pre_tag_str = if pre.has_tag() {
1173 pre.format(false)
1174 } else {
1175 String::new()
1176 };
1177
1178 let with_dash = |s: &str| {
1179 if s.is_empty() {
1180 String::new()
1181 } else {
1182 format!("-{s}")
1183 }
1184 };
1185
1186 let major_minor_patch = sv.major_minor_patch();
1187 let sem_ver = sv.to_string();
1188 let commits = sv.build_metadata.version_source_distance;
1189 let full_build_meta = sv.build_metadata.format_full();
1190
1191 let full_sem_ver = match sv.build_metadata.commits_since_tag {
1193 Some(n) => format!("{sem_ver}+{n}"),
1194 None => sem_ver.clone(),
1195 };
1196
1197 let weighted = Some(match pre_number {
1200 Some(n) => n + eff.pre_release_weight,
1201 None => eff.tag_pre_release_weight,
1202 });
1203
1204 let assembly_sem_ver = assembly_version(sv, eff.assembly_versioning_scheme);
1205 let assembly_sem_file_ver = assembly_version(sv, eff.assembly_file_versioning_scheme);
1206 let informational = if full_build_meta.is_empty() {
1208 sem_ver.clone()
1209 } else {
1210 format!("{sem_ver}+{full_build_meta}")
1211 };
1212
1213 let escaped_branch = Regex::new(r"[^a-zA-Z0-9-]")
1214 .unwrap()
1215 .replace_all(branch_name, "-")
1216 .into_owned();
1217
1218 let date_fmt = dotnet_date_format_to_strftime(&eff.commit_date_format);
1219 let commit_date = head.when.naive_utc().format(&date_fmt).to_string();
1220
1221 let mut vars = VersionVariables {
1222 major: sv.major as u32,
1223 minor: sv.minor as u32,
1224 patch: sv.patch as u32,
1225 pre_release_tag: pre_tag_str.clone(),
1226 pre_release_tag_with_dash: with_dash(&pre_tag_str),
1227 pre_release_label: pre_label.clone(),
1228 pre_release_label_with_dash: with_dash(&pre_label),
1229 pre_release_number: pre_number,
1230 weighted_pre_release_number: weighted,
1231 build_meta_data: sv.build_metadata.commits_since_tag,
1232 full_build_meta_data: full_build_meta,
1233 major_minor_patch,
1234 sem_ver,
1235 full_sem_ver,
1236 assembly_sem_ver,
1237 assembly_sem_file_ver,
1238 informational_version: informational,
1239 branch_name: branch_name.to_string(),
1240 escaped_branch_name: escaped_branch,
1241 sha: head.sha.clone(),
1242 short_sha: head.short_sha.clone(),
1243 version_source_distance: Some(commits),
1244 version_source_increment: sv
1245 .build_metadata
1246 .version_source_increment
1247 .as_str()
1248 .to_string(),
1249 version_source_sem_ver: source_semver.to_string(),
1250 version_source_sha: sv
1251 .build_metadata
1252 .version_source_sha
1253 .clone()
1254 .unwrap_or_default(),
1255 commits_since_version_source: Some(commits),
1256 commit_date,
1257 uncommitted_changes: sv.build_metadata.uncommitted_changes,
1258 };
1259
1260 let ctx = vars.to_map();
1263 if let Some(fmt) = &eff.assembly_versioning_format {
1264 vars.assembly_sem_ver = render_template(fmt, &ctx)?;
1265 }
1266 if let Some(fmt) = &eff.assembly_file_versioning_format {
1267 vars.assembly_sem_file_ver = render_template(fmt, &ctx)?;
1268 }
1269 vars.informational_version = render_template(&eff.assembly_informational_format, &ctx)?;
1272
1273 Ok(vars)
1274}
1275
1276fn render_template(fmt: &str, ctx: &std::collections::BTreeMap<String, String>) -> Result<String> {
1280 let re = Regex::new(r"\{(?<t>[A-Za-z0-9_:]+)\}").unwrap();
1281 let mut unknown: Option<String> = None;
1282 let out = re
1283 .replace_all(fmt, |c: ®ex::Captures| {
1284 let t = &c["t"];
1285 if let Some(env_var) = t.strip_prefix("env:") {
1286 std::env::var(env_var).unwrap_or_default()
1287 } else if let Some(v) = ctx.get(t) {
1288 v.clone()
1289 } else {
1290 if unknown.is_none() {
1291 unknown = Some(t.to_string());
1292 }
1293 String::new()
1294 }
1295 })
1296 .into_owned();
1297 match unknown {
1298 Some(t) => Err(anyhow::anyhow!(
1299 "Unknown template token '{{{t}}}' in format string"
1300 )),
1301 None => Ok(out),
1302 }
1303}
1304
1305fn assembly_version(sv: &SemanticVersion, scheme: VersioningScheme) -> String {
1307 let pre = sv.pre_release_tag.number.unwrap_or(0);
1308 match scheme {
1309 VersioningScheme::Major => format!("{}.0.0.0", sv.major),
1310 VersioningScheme::MajorMinor => format!("{}.{}.0.0", sv.major, sv.minor),
1311 VersioningScheme::MajorMinorPatch => {
1312 format!("{}.{}.{}.0", sv.major, sv.minor, sv.patch)
1313 }
1314 VersioningScheme::MajorMinorPatchTag => {
1315 format!("{}.{}.{}.{}", sv.major, sv.minor, sv.patch, pre)
1316 }
1317 VersioningScheme::None => String::new(),
1318 }
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323 use super::*;
1324 use crate::config::defaults;
1325
1326 fn default_eff() -> EffectiveConfiguration {
1327 let cfg = defaults::gitflow();
1328 EffectiveConfiguration::resolve(&cfg, "main")
1329 }
1330
1331 #[test]
1332 fn validate_config_regexes_rejects_bad_patterns() {
1333 let eff = default_eff();
1335 assert!(validate_config_regexes(&eff).is_ok());
1336 let mut bad_prefix = default_eff();
1338 bad_prefix.tag_prefix = "(unclosed".to_string();
1339 assert!(validate_config_regexes(&bad_prefix).is_err());
1340 let mut bad_bump = default_eff();
1342 bad_bump.major_bump_message = "[invalid".to_string();
1343 assert!(validate_config_regexes(&bad_bump).is_err());
1344 let mut disabled = default_eff();
1346 disabled.major_bump_message = "[invalid".to_string();
1347 disabled.commit_message_incrementing = CommitMessageIncrementMode::Disabled;
1348 assert!(validate_config_regexes(&disabled).is_ok());
1349 }
1350
1351 #[test]
1352 fn render_template_errors_on_unknown_token() {
1353 let mut ctx = std::collections::BTreeMap::new();
1354 ctx.insert("Major".to_string(), "1".to_string());
1355 assert_eq!(render_template("v{Major}", &ctx).unwrap(), "v1");
1357 assert!(render_template("{env:GV_NO_SUCH_VAR}", &ctx).is_ok());
1359 assert!(render_template("{Bogus}", &ctx).is_err());
1361 }
1362
1363 #[test]
1364 fn parse_ignore_date_formats() {
1365 let dt = parse_ignore_date("2021-06-15T12:00:00").unwrap();
1367 assert!(dt.to_rfc3339().starts_with("2021-06-15"));
1368 let dt2 = parse_ignore_date("2021-06-15").unwrap();
1370 assert!(dt2.to_rfc3339().starts_with("2021-06-15"));
1371 let dt3 = parse_ignore_date("2021-06-15 12:00:00").unwrap();
1373 assert!(dt3.to_rfc3339().starts_with("2021-06-15"));
1374 assert!(parse_ignore_date("not-a-date").is_none());
1376 }
1377
1378 #[test]
1379 fn ignore_set_sha_prefix_match() {
1380 let full_sha = "abcdef1234567890abcdef1234567890abcdef12";
1382 let prefix = "abcdef1"; let mut set = IgnoreSet::default();
1384 set.shas.insert(prefix.to_lowercase());
1385 let when = chrono::Utc::now().fixed_offset();
1386 assert!(set.is_ignored(full_sha, &when));
1387 let mut set2 = IgnoreSet::default();
1389 set2.shas.insert("abcdef".to_lowercase()); assert!(!set2.is_ignored(full_sha, &when));
1391 }
1392
1393 #[test]
1394 fn ignore_set_before_date() {
1395 let past = parse_ignore_date("2020-01-01").unwrap();
1396 let set = IgnoreSet {
1397 before: Some(parse_ignore_date("2021-01-01").unwrap()),
1398 ..Default::default()
1399 };
1400 assert!(set.is_ignored("anysha", &past));
1402 let future = parse_ignore_date("2022-01-01").unwrap();
1404 assert!(!set.is_ignored("anysha", &future));
1405 }
1406
1407 #[test]
1408 fn strategy_to_field_all_variants() {
1409 assert_eq!(
1410 strategy_to_field(IncrementStrategy::Major),
1411 VersionField::Major
1412 );
1413 assert_eq!(
1414 strategy_to_field(IncrementStrategy::Minor),
1415 VersionField::Minor
1416 );
1417 assert_eq!(
1418 strategy_to_field(IncrementStrategy::Patch),
1419 VersionField::Patch
1420 );
1421 assert_eq!(
1422 strategy_to_field(IncrementStrategy::None),
1423 VersionField::None
1424 );
1425 assert_eq!(
1426 strategy_to_field(IncrementStrategy::Inherit),
1427 VersionField::None
1428 );
1429 }
1430
1431 #[test]
1432 fn increment_from_message_all_levels() {
1433 let eff = default_eff();
1434 assert_eq!(
1436 increment_from_message("big change\n+semver: major", &eff),
1437 Some(VersionField::Major)
1438 );
1439 assert_eq!(
1441 increment_from_message("new feature\n+semver: minor", &eff),
1442 Some(VersionField::Minor)
1443 );
1444 assert_eq!(
1446 increment_from_message("small fix\n+semver: patch", &eff),
1447 Some(VersionField::Patch)
1448 );
1449 assert_eq!(
1451 increment_from_message("chore\n+semver: none", &eff),
1452 Some(VersionField::None)
1453 );
1454 assert_eq!(
1455 increment_from_message("+semver: skip", &eff),
1456 Some(VersionField::None)
1457 );
1458 assert_eq!(increment_from_message("ordinary commit", &eff), None);
1460 }
1461
1462 #[test]
1463 fn increment_from_message_breaking_alias() {
1464 let eff = default_eff();
1465 assert_eq!(
1466 increment_from_message("+semver: breaking", &eff),
1467 Some(VersionField::Major)
1468 );
1469 assert_eq!(
1470 increment_from_message("+semver: feature", &eff),
1471 Some(VersionField::Minor)
1472 );
1473 assert_eq!(
1474 increment_from_message("+semver: fix", &eff),
1475 Some(VersionField::Patch)
1476 );
1477 }
1478
1479 #[test]
1480 fn ignore_set_filter_empty_shortcircuit() {
1481 use crate::git::CommitInfo;
1483 let set = IgnoreSet::default();
1484 let commits = vec![CommitInfo {
1485 sha: "abc".into(),
1486 short_sha: "abc".into(),
1487 message: "msg".into(),
1488 when: chrono::Utc::now().fixed_offset(),
1489 parent_count: 0,
1490 parents: vec![],
1491 }];
1492 assert!(set.shas.is_empty() && set.before.is_none() && set.paths.is_empty());
1496 let _ = commits; }
1498}