use crate::config::{
effective::EffectiveConfiguration, CommitMessageIncrementMode, DeploymentMode,
GitVersionConfiguration, IncrementStrategy, SemanticVersionFormat, VersionStrategy,
VersioningScheme,
};
use crate::git::{CommitInfo, GitRepo};
use crate::output::variables::VersionVariables;
use crate::version::{BuildMetaData, PreReleaseTag, SemanticVersion, VersionField};
use anyhow::Result;
use chrono::{DateTime, FixedOffset, NaiveDateTime, TimeZone};
use regex::Regex;
use std::collections::HashSet;
#[derive(Debug, Clone, Default)]
struct IgnoreSet {
shas: HashSet<String>,
before: Option<DateTime<FixedOffset>>,
paths: Vec<String>,
}
impl IgnoreSet {
fn from_config(config: &GitVersionConfiguration) -> Self {
let shas: HashSet<String> = config.ignore.sha.iter().map(|s| s.to_lowercase()).collect();
let before = config
.ignore
.commits_before
.as_deref()
.and_then(parse_ignore_date);
let paths = config.ignore.paths.clone();
IgnoreSet {
shas,
before,
paths,
}
}
fn is_ignored(&self, sha: &str, when: &DateTime<FixedOffset>) -> bool {
if self.shas.contains(&sha.to_lowercase()) {
return true;
}
if self
.shas
.iter()
.any(|s| sha.to_lowercase().starts_with(s.as_str()) && s.len() >= 7)
{
return true;
}
matches!(&self.before, Some(b) if when < b)
}
fn is_path_ignored(&self, repo: &crate::git::GitRepo, sha: &str) -> bool {
if self.paths.is_empty() {
return false;
}
let changed = repo.changed_paths_for_commit(sha);
if changed.is_empty() {
return true;
}
changed.iter().all(|file| {
self.paths.iter().any(|prefix| {
let prefix = prefix.trim_end_matches('/');
file == prefix || file.starts_with(&format!("{prefix}/"))
})
})
}
fn filter(&self, repo: &crate::git::GitRepo, commits: Vec<CommitInfo>) -> Vec<CommitInfo> {
if self.shas.is_empty() && self.before.is_none() && self.paths.is_empty() {
return commits;
}
commits
.into_iter()
.filter(|c| !self.is_ignored(&c.sha, &c.when) && !self.is_path_ignored(repo, &c.sha))
.collect()
}
}
fn parse_ignore_date(s: &str) -> Option<DateTime<FixedOffset>> {
let s = s.trim();
for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] {
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, fmt) {
return Some(chrono::Utc.from_utc_datetime(&ndt).fixed_offset());
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, fmt) {
if let Some(ndt) = d.and_hms_opt(0, 0, 0) {
return Some(chrono::Utc.from_utc_datetime(&ndt).fixed_offset());
}
}
}
None
}
fn dotnet_date_format_to_strftime(fmt: &str) -> String {
let mut out = fmt.to_string();
for (from, to) in [
("yyyy", "%Y"),
("yy", "%y"),
("MMMM", "%B"),
("MMM", "%b"),
("MM", "%m"),
("dddd", "%A"),
("ddd", "%a"),
("dd", "%d"),
("HH", "%H"),
("mm", "%M"),
("ss", "%S"),
] {
out = out.replace(from, to);
}
out
}
#[derive(Debug, Clone)]
struct BaseVersion {
source: String,
semantic_version: SemanticVersion,
base_version_source: Option<String>,
source_when: Option<DateTime<FixedOffset>>,
increment: VersionField,
label: Option<String>,
force_increment: bool,
exact: bool,
}
impl BaseVersion {
fn new(
source: impl Into<String>,
semantic_version: SemanticVersion,
base_version_source: Option<String>,
increment: VersionField,
label: Option<String>,
) -> Self {
Self {
source: source.into(),
semantic_version,
base_version_source,
source_when: None,
increment,
label,
force_increment: false,
exact: false,
}
}
}
#[derive(Debug, Clone)]
struct NextVersion {
incremented: SemanticVersion,
base: BaseVersion,
}
fn strategy_to_field(s: IncrementStrategy) -> VersionField {
match s {
IncrementStrategy::Major => VersionField::Major,
IncrementStrategy::Minor => VersionField::Minor,
IncrementStrategy::Patch => VersionField::Patch,
IncrementStrategy::None | IncrementStrategy::Inherit => VersionField::None,
}
}
fn increment_from_message(msg: &str, eff: &EffectiveConfiguration) -> Option<VersionField> {
let test = |pat: &str| {
Regex::new(&format!("(?im){pat}"))
.map(|r| r.is_match(msg))
.unwrap_or(false)
};
if test(&eff.major_bump_message) {
Some(VersionField::Major)
} else if test(&eff.minor_bump_message) {
Some(VersionField::Minor)
} else if test(&eff.patch_bump_message) {
Some(VersionField::Patch)
} else if test(&eff.no_bump_message) {
Some(VersionField::None)
} else {
None
}
}
fn determine_increment(
repo: &GitRepo,
base_source: Option<&str>,
head_sha: &str,
should_increment: bool,
eff: &EffectiveConfiguration,
ignore: &IgnoreSet,
) -> Result<VersionField> {
let default_increment = strategy_to_field(eff.increment);
let message_increment =
if eff.commit_message_incrementing == CommitMessageIncrementMode::Disabled {
None
} else {
let commits = ignore.filter(repo, repo.commits_between(base_source, head_sha)?);
let merge_only =
eff.commit_message_incrementing == CommitMessageIncrementMode::MergeMessageOnly;
let mut best: Option<VersionField> = None;
for c in &commits {
if merge_only && c.parent_count < 2 {
continue;
}
if let Some(f) = increment_from_message(&c.message, eff) {
best = Some(best.map_or(f, |b| b.max(f)));
}
}
best
};
Ok(match message_increment {
None => {
if should_increment {
default_increment
} else {
VersionField::None
}
}
Some(mi) => {
if should_increment && mi < default_increment {
default_increment
} else {
mi
}
}
})
}
fn parse_version(input: &str, eff: &EffectiveConfiguration) -> Option<SemanticVersion> {
let strict = eff.semantic_version_format == SemanticVersionFormat::Strict;
SemanticVersion::parse_with(input, &eff.tag_prefix, strict)
}
fn validate_config_regexes(eff: &EffectiveConfiguration) -> Result<()> {
let check = |label: &str, pat: &str| -> Result<()> {
Regex::new(pat)
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Invalid {label} regex '{pat}': {e}"))
};
check("tag-prefix", &eff.tag_prefix)?;
if eff.commit_message_incrementing != CommitMessageIncrementMode::Disabled {
check("major-version-bump-message", &eff.major_bump_message)?;
check("minor-version-bump-message", &eff.minor_bump_message)?;
check("patch-version-bump-message", &eff.patch_bump_message)?;
check("no-bump-message", &eff.no_bump_message)?;
}
Ok(())
}
fn extract_version(text: &str, eff: &EffectiveConfiguration) -> Option<SemanticVersion> {
let pattern = format!(
"(?i)^{}",
eff.version_in_branch_pattern.trim_start_matches('^')
);
let re = Regex::new(&pattern).ok()?;
let sep = if text.contains('/') || !text.contains('-') {
'/'
} else {
'-'
};
for part in text.split(sep) {
if part.is_empty() {
continue;
}
if let Some(caps) = re.captures(part) {
let raw = caps
.name("version")
.map(|m| m.as_str())
.unwrap_or_else(|| caps.get(0).unwrap().as_str());
if let Some(v) = parse_version(raw, eff) {
return Some(v);
}
}
}
None
}
fn resolve_inherit_via_git(
repo: &GitRepo,
config: &GitVersionConfiguration,
branch_name: &str,
) -> Result<Option<IncrementStrategy>> {
let Some((_, bc)) = crate::config::effective::find_branch_config(config, branch_name) else {
return Ok(None);
};
let own = bc
.increment
.or(config.increment)
.unwrap_or(IncrementStrategy::Inherit);
if own != IncrementStrategy::Inherit {
return Ok(None);
}
let repo_branches = repo.branch_names().unwrap_or_default();
let mut best: Option<(i64, IncrementStrategy)> = None;
for src_key in &bc.source_branches {
let Some(src_bc) = config.branches.get(src_key) else {
continue;
};
let Some(re_src) = &src_bc.regex else {
continue;
};
let Ok(re) = Regex::new(&format!("(?i){re_src}")) else {
continue;
};
for rb in &repo_branches {
if rb == branch_name {
continue;
}
let short = rb.rsplit('/').next().unwrap_or(rb);
if !(re.is_match(rb) || re.is_match(short)) {
continue;
}
let Some(mb) = repo.merge_base(branch_name, rb)? else {
continue;
};
let depth = repo.commits_between(None, &mb)?.len() as i64;
let inc = crate::config::effective::resolve_increment(config, src_bc, 0);
if best.map(|(d, _)| depth > d).unwrap_or(true) {
best = Some((depth, inc));
}
}
}
Ok(best.map(|(_, inc)| inc))
}
const BUILTIN_MERGE_FORMATS: &[&str] = &[
r"^Merge (branch|tag) '(?<SourceBranch>[^']*)'(?: into (?<TargetBranch>[^\s]*))*",
r"^Finish (?<SourceBranch>[^\s]*)(?: into (?<TargetBranch>[^\s]*))*",
r"^Merge pull request #(?<PullRequestNumber>\d+) (from|in) (?<Source>.*) from (?<SourceBranch>[^\s]*) to (?<TargetBranch>[^\s]*)",
r"^Pull request #(?<PullRequestNumber>\d+)[^\r\n]*\r?\n\r?\nMerge in (?<Source>[^\r\n]*) from (?<SourceBranch>[^\s]*) to (?<TargetBranch>[^\s]*)",
r"^Merged in (?<SourceBranch>[^\s]*) \(pull request #(?<PullRequestNumber>\d+)\)",
r"^Merge pull request #(?<PullRequestNumber>\d+) (from|in) (?:[^\s/]+/)?(?<SourceBranch>[^\s]*)(?: into (?<TargetBranch>[^\s]*))*",
r"^Merge remote-tracking branch '(?<SourceBranch>[^\s]*)'(?: into (?<TargetBranch>[^\s]*))*",
r"^Merge pull request (?<PullRequestNumber>\d+) from (?<SourceBranch>[^\s]*) into (?<TargetBranch>[^\s]*)",
];
fn parse_merge_message(
message: &str,
eff: &EffectiveConfiguration,
) -> Option<(String, SemanticVersion)> {
let from_branch = |sb: &str| -> Option<SemanticVersion> {
parse_version(sb, eff).or_else(|| extract_version(sb, eff))
};
let custom = eff.merge_message_formats.values().map(|s| s.as_str());
for pattern in custom.chain(BUILTIN_MERGE_FORMATS.iter().copied()) {
let Ok(re) = Regex::new(&format!("(?s){pattern}")) else {
continue;
};
let Some(caps) = re.captures(message) else {
continue;
};
let Some(sb) = caps.name("SourceBranch") else {
continue;
};
let branch = sb.as_str().to_string();
if let Some(v) = caps
.name("Version")
.and_then(|m| parse_version(m.as_str(), eff))
{
return Some((branch, v));
}
if let Some(v) = from_branch(&branch) {
return Some((branch, v));
}
return None; }
None
}
fn merge_branch_increment(config: &GitVersionConfiguration, message: &str) -> Option<VersionField> {
for pattern in BUILTIN_MERGE_FORMATS {
let Ok(re) = Regex::new(&format!("(?s){pattern}")) else {
continue;
};
let Some(caps) = re.captures(message) else {
continue;
};
let Some(sb) = caps.name("SourceBranch") else {
continue;
};
let branch = sb.as_str();
let (_, bc) = crate::config::effective::find_branch_config(config, branch)?;
if bc
.prevent_increment
.as_ref()
.and_then(|pi| pi.when_branch_merged)
.unwrap_or(false)
{
return Some(VersionField::None);
}
let increment = bc.increment.unwrap_or(IncrementStrategy::Inherit);
if matches!(
increment,
IncrementStrategy::Inherit | IncrementStrategy::None
) {
return None;
}
return Some(strategy_to_field(increment));
}
None
}
fn is_release_branch(config: &GitVersionConfiguration, branch_name: &str) -> bool {
let short = branch_name.rsplit('/').next().unwrap_or(branch_name);
config.branches.values().any(|bc| {
bc.is_release_branch == Some(true)
&& bc
.regex
.as_ref()
.and_then(|r| Regex::new(&format!("(?i){r}")).ok())
.map(|re| re.is_match(branch_name) || re.is_match(short))
.unwrap_or(false)
})
}
pub fn calculate(
repo: &GitRepo,
config: &GitVersionConfiguration,
branch_override: Option<String>,
) -> Result<VersionVariables> {
let (head, branch_name) = match &branch_override {
Some(b) => {
let head = repo
.commit_info_of(b)
.map(Ok)
.unwrap_or_else(|| repo.head_commit())?;
(head, b.clone())
}
None => (repo.head_commit()?, repo.current_branch_name()?),
};
let mut eff = EffectiveConfiguration::resolve(config, &branch_name);
validate_config_regexes(&eff)?;
let ignore = IgnoreSet::from_config(config);
if config.strategies.contains(&VersionStrategy::Mainline) {
return mainline_calculate(repo, config, &eff, &branch_name, &head, &ignore);
}
if let Some(inc) = resolve_inherit_via_git(repo, config, &branch_name)? {
eff.increment = inc;
}
let mut candidates: Vec<BaseVersion> = Vec::new();
let mut tag_alternatives: Vec<SemanticVersion> = Vec::new();
let strategies = if config.strategies.is_empty() {
vec![
VersionStrategy::Fallback,
VersionStrategy::ConfiguredNextVersion,
VersionStrategy::MergeMessage,
VersionStrategy::TaggedCommit,
VersionStrategy::VersionInBranchName,
]
} else {
config.strategies.clone()
};
for strat in &strategies {
match strat {
VersionStrategy::ConfiguredNextVersion => {
if let Some(nv) = &eff.next_version {
if !nv.is_empty() {
let v = parse_version(nv, &eff).ok_or_else(|| {
anyhow::anyhow!("Failed to parse {nv} into a Semantic Version")
})?;
let label_ok =
!v.pre_release_tag.has_tag() || v.pre_release_tag.name == eff.label;
if label_ok {
candidates.push(BaseVersion::new(
"ConfiguredNextVersion",
v,
None,
VersionField::None,
Some(eff.label.clone()),
));
}
}
}
}
VersionStrategy::TaggedCommit | VersionStrategy::Mainline => {
gather_tagged(
repo,
&eff,
&head,
&ignore,
&mut candidates,
&mut tag_alternatives,
)?;
}
VersionStrategy::VersionInBranchName => {
if eff.is_release_branch {
if let Some(v) = extract_version(&branch_name, &eff) {
candidates.push(BaseVersion::new(
"VersionInBranchName",
v,
None,
VersionField::None,
Some(eff.label.clone()),
));
}
}
}
VersionStrategy::MergeMessage => {
if eff.track_merge_message {
gather_merge_messages(repo, config, &eff, &head, &ignore, &mut candidates)?;
}
}
VersionStrategy::TrackReleaseBranches => {
gather_track_release(repo, config, &eff, &head, &branch_name, &mut candidates)?;
}
VersionStrategy::Fallback => {
let field = determine_increment(repo, None, &head.sha, true, &eff, &ignore)?;
candidates.push(BaseVersion::new(
"Fallback (0.0.0)",
SemanticVersion::new(0, 0, 0),
None,
field,
Some(eff.label.clone()),
));
}
VersionStrategy::None => {}
}
}
if candidates.is_empty() {
return Err(anyhow::anyhow!(
"No base versions determined on the current branch."
));
}
let next: Vec<NextVersion> = candidates
.into_iter()
.map(|b| {
let incremented = if b.exact {
b.semantic_version.clone()
} else {
b.semantic_version
.increment(b.increment, b.label.as_deref(), b.force_increment)
};
NextVersion {
incremented,
base: b,
}
})
.collect();
let max_idx = next.iter().enumerate().fold(0usize, |acc, (i, n)| {
if n.incremented.cmp(&next[acc].incremented) == std::cmp::Ordering::Greater {
i
} else {
acc
}
});
let latest_source = next
.iter()
.filter(|n| n.base.base_version_source.is_some())
.max_by(|a, b| a.base.source_when.cmp(&b.base.source_when));
let base_source = latest_source
.and_then(|n| n.base.base_version_source.clone())
.or_else(|| next[max_idx].base.base_version_source.clone());
let chosen = next.into_iter().nth(max_idx).unwrap();
let source_semver = chosen.base.semantic_version.clone();
let mut final_semver = apply_deployment_mode(
repo,
&eff,
&branch_name,
&head,
&chosen,
base_source.as_deref(),
&ignore,
)?;
if let Some(alt) = tag_alternatives.iter().max_by(|a, b| a.cmp_core(b)) {
if alt.cmp_core(&final_semver) == std::cmp::Ordering::Greater {
final_semver.major = alt.major;
final_semver.minor = alt.minor;
final_semver.patch = alt.patch;
}
}
let variables = build_variables(&eff, &branch_name, &head, &final_semver, &source_semver)?;
Ok(variables)
}
fn mainline_calculate(
repo: &GitRepo,
config: &GitVersionConfiguration,
eff: &EffectiveConfiguration,
branch_name: &str,
head: &CommitInfo,
ignore: &IgnoreSet,
) -> Result<VersionVariables> {
let mut tags_by_sha: std::collections::HashMap<String, SemanticVersion> =
std::collections::HashMap::new();
for tag in repo.tags()? {
if ignore.is_ignored(&tag.target_sha, &tag.when) {
continue;
}
if let Some(v) = parse_version(&tag.name, eff) {
let core = SemanticVersion::new(v.major, v.minor, v.patch);
let e = tags_by_sha
.entry(tag.target_sha.clone())
.or_insert_with(|| core.clone());
if core.cmp_core(e) == std::cmp::Ordering::Greater {
*e = core;
}
}
}
let core_gt =
|a: &SemanticVersion, b: &SemanticVersion| a.cmp_core(b) == std::cmp::Ordering::Greater;
let default = strategy_to_field(eff.increment);
let merge_base_sha: Option<String> = if !eff.is_main_branch && !eff.source_branches.is_empty() {
let src = &eff.source_branches[0];
if let Some(src_info) = repo.commit_info_of(src) {
repo.merge_base(&head.sha, &src_info.sha)?
} else {
None
}
} else {
None
};
let trunk_target = merge_base_sha.as_deref().unwrap_or(&head.sha);
let trunk_eff_buf;
let trunk_eff: &EffectiveConfiguration = if merge_base_sha.is_some() {
trunk_eff_buf = EffectiveConfiguration::resolve(config, &eff.source_branches[0]);
&trunk_eff_buf
} else {
eff
};
let trunk_default = strategy_to_field(trunk_eff.increment);
let mut trunk = ignore.filter(repo, repo.first_parent_between(None, trunk_target)?);
trunk.reverse();
let mut version = SemanticVersion::new(0, 0, 0);
let mut highest_tag = SemanticVersion::new(0, 0, 0);
let mut prev_trunk_version = SemanticVersion::new(0, 0, 0);
for c in &trunk {
prev_trunk_version = version.clone();
let introduced: Vec<CommitInfo> = if c.parents.len() >= 2 {
ignore.filter(
repo,
repo.commits_between(Some(&c.parents[0]), &c.parents[1])?,
)
} else {
vec![c.clone()]
};
let mut step_tag: Option<SemanticVersion> = None;
for sha in introduced
.iter()
.map(|x| &x.sha)
.chain(std::iter::once(&c.sha))
{
if let Some(tv) = tags_by_sha.get(sha) {
if step_tag.as_ref().map(|s| core_gt(tv, s)).unwrap_or(true) {
step_tag = Some(tv.clone());
}
}
}
if let Some(tv) = step_tag {
if core_gt(&tv, &highest_tag) {
highest_tag = tv.clone();
}
if !core_gt(&version, &tv) {
version = tv;
continue;
}
}
let mut field = trunk_default;
for ic in &introduced {
if let Some(f) = increment_from_message(&ic.message, trunk_eff) {
if f > field {
field = f;
}
}
}
if c.parents.len() >= 2 {
match merge_branch_increment(config, &c.message) {
Some(VersionField::None) => {
field = VersionField::None;
}
Some(branch_field) if branch_field > field => {
field = branch_field;
}
_ => {}
}
}
version = version.increment(field, None, true);
}
let trunk_version_end = version.clone();
let (mut version, source_sha, distance) = if let Some(ref mb_sha) = merge_base_sha {
let feature_commits = ignore.filter(repo, repo.commits_between(Some(mb_sha), &head.sha)?);
let head_is_tagged = tags_by_sha.contains_key(&head.sha);
let feature_tag = feature_commits
.iter()
.filter_map(|c| {
tags_by_sha
.get(&c.sha)
.map(|tv| (c.sha.clone(), tv.clone()))
})
.reduce(|(sa, a), (sb, b)| if core_gt(&b, &a) { (sb, b) } else { (sa, a) });
if let Some((ft_sha, ft)) = feature_tag {
if head_is_tagged && !eff.prevent_increment_when_current_commit_tagged {
let v = ft.increment(default, None, true);
(v, Some(head.sha.clone()), 0i64)
} else {
let d = repo.commits_between(Some(&ft_sha), &head.sha)?.len() as i64;
(ft, Some(ft_sha), d)
}
} else {
let v = version.increment(default, None, true);
let d = feature_commits.len() as i64;
(v, Some(mb_sha.clone()), d)
}
} else {
let head_is_tagged = tags_by_sha.contains_key(&head.sha);
if head_is_tagged && !eff.prevent_increment_when_current_commit_tagged {
let v = version.increment(default, None, true);
(v, Some(head.sha.clone()), 0i64)
} else {
let s = head.parents.first().cloned();
let d = repo.commits_between(s.as_deref(), &head.sha)?.len() as i64;
(version, s, d)
}
};
let label = eff.label.as_str();
let mut commits_since_tag = None;
version.pre_release_tag = match eff.deployment_mode {
DeploymentMode::ContinuousDeployment => PreReleaseTag::default(),
DeploymentMode::ContinuousDelivery => {
PreReleaseTag::new(label, Some(distance), label.is_empty())
}
DeploymentMode::ManualDeployment => {
commits_since_tag = Some(distance);
PreReleaseTag::new(label, Some(1), label.is_empty())
}
};
version.build_metadata = BuildMetaData {
commits_since_tag,
branch: Some(branch_name.to_string()),
sha: Some(head.sha.clone()),
short_sha: Some(head.short_sha.clone()),
commit_date: Some(head.when),
version_source_sha: source_sha,
version_source_distance: distance,
uncommitted_changes: repo.uncommitted_changes().unwrap_or(0),
version_source_increment: VersionField::None,
other_metadata: None,
};
let version_at_source = if merge_base_sha.is_some() {
trunk_version_end
} else {
prev_trunk_version.clone()
};
let source_semver = match version.build_metadata.version_source_sha.as_deref() {
None => SemanticVersion::new(0, 0, 0),
Some(sha) => {
if let Some(tv) = tags_by_sha.get(sha) {
tv.clone()
} else {
let mut sv = version_at_source;
sv.pre_release_tag = PreReleaseTag::new("", Some(1), true);
sv
}
}
};
build_variables(eff, branch_name, head, &version, &source_semver)
}
fn is_match_for_branch_label(version: &SemanticVersion, label: &str) -> bool {
let pre = &version.pre_release_tag;
if pre.name.is_empty() && pre.number.is_none() {
return true;
}
pre.has_tag() && pre.name == label
}
fn gather_tagged(
repo: &GitRepo,
eff: &EffectiveConfiguration,
head: &CommitInfo,
ignore: &IgnoreSet,
out: &mut Vec<BaseVersion>,
alternatives: &mut Vec<SemanticVersion>,
) -> Result<()> {
for tag in repo.tags()? {
if ignore.is_ignored(&tag.target_sha, &tag.when) {
continue;
}
if ignore.is_path_ignored(repo, &tag.target_sha) {
continue;
}
if !repo
.is_ancestor_of(&tag.target_sha, &head.sha)
.unwrap_or(false)
{
continue;
}
let Some(version) = parse_version(&tag.name, eff) else {
continue;
};
alternatives.push(version.clone());
if !is_match_for_branch_label(&version, &eff.label) {
continue;
}
let is_current = tag.target_sha == head.sha;
let exact = is_current && eff.prevent_increment_when_current_commit_tagged;
let has_pre = version.pre_release_tag.has_tag();
let is_numeric_only_pre = has_pre && version.pre_release_tag.name.is_empty();
let use_as_source = exact || !has_pre || is_numeric_only_pre;
let base_src = if use_as_source {
Some(tag.target_sha.clone())
} else {
None
};
let field = if exact {
VersionField::None
} else {
let from = if use_as_source {
Some(tag.target_sha.as_str())
} else {
None
};
determine_increment(repo, from, &head.sha, true, eff, ignore)?
};
let mut bv = BaseVersion::new(
format!("Tag {}", tag.name),
version,
base_src,
field,
Some(eff.label.clone()),
);
bv.exact = exact;
bv.source_when = if use_as_source { Some(tag.when) } else { None };
out.push(bv);
}
Ok(())
}
fn gather_merge_messages(
repo: &GitRepo,
config: &GitVersionConfiguration,
eff: &EffectiveConfiguration,
head: &CommitInfo,
ignore: &IgnoreSet,
out: &mut Vec<BaseVersion>,
) -> Result<()> {
let mut count = 0usize;
for c in ignore.filter(repo, repo.commits_between(None, &head.sha)?) {
if count >= 5 {
break;
}
let Some((merged_branch, v)) = parse_merge_message(&c.message, eff) else {
continue;
};
if !is_release_branch(config, &merged_branch) {
continue;
}
let base_src = if c.parents.len() >= 2 {
repo.merge_base(&c.parents[0], &c.parents[1])?
.unwrap_or_else(|| c.sha.clone())
} else {
c.sha.clone()
};
let field = if eff.prevent_increment_of_merged_branch {
VersionField::None
} else {
determine_increment(repo, Some(&base_src), &head.sha, true, eff, ignore)?
};
let mut bv = BaseVersion::new(
"MergeMessage",
v,
Some(base_src),
field,
Some(eff.label.clone()),
);
bv.source_when = Some(c.when);
out.push(bv);
count += 1;
}
Ok(())
}
fn gather_track_release(
repo: &GitRepo,
config: &GitVersionConfiguration,
eff: &EffectiveConfiguration,
head: &CommitInfo,
branch_name: &str,
out: &mut Vec<BaseVersion>,
) -> Result<()> {
if !eff.tracks_release_branches {
return Ok(());
}
let Some((_, release_bc)) = config
.branches
.iter()
.find(|(k, _)| k.as_str() == "release")
else {
return Ok(());
};
let Some(re_src) = &release_bc.regex else {
return Ok(());
};
let Ok(re) = Regex::new(&format!("(?i){re_src}")) else {
return Ok(());
};
for rb in repo.branch_names()? {
let short = rb.rsplit('/').next().unwrap_or(&rb);
if !(re.is_match(&rb) || re.is_match(short)) {
continue;
}
if let Some(v) = extract_version(&rb, eff) {
let base_src = repo.merge_base(branch_name, &rb)?;
out.push(BaseVersion::new(
format!("TrackReleaseBranches: {rb}"),
v,
base_src.or(Some(head.sha.clone())),
strategy_to_field(eff.increment),
Some(eff.label.clone()),
));
}
}
Ok(())
}
fn apply_deployment_mode(
repo: &GitRepo,
eff: &EffectiveConfiguration,
branch_name: &str,
head: &CommitInfo,
chosen: &NextVersion,
base_source: Option<&str>,
ignore: &IgnoreSet,
) -> Result<SemanticVersion> {
let base_src = if chosen.base.exact {
chosen.base.base_version_source.as_deref()
} else {
base_source
};
let commits = ignore
.filter(repo, repo.commits_between(base_src, &head.sha)?)
.len() as i64;
let uncommitted = repo.uncommitted_changes().unwrap_or(0);
let mut sv = chosen.incremented.clone();
let mut meta = BuildMetaData {
commits_since_tag: Some(commits),
branch: Some(branch_name.to_string()),
sha: Some(head.sha.clone()),
short_sha: Some(head.short_sha.clone()),
commit_date: Some(head.when),
version_source_sha: base_src.map(|s| s.to_string()),
version_source_distance: commits,
uncommitted_changes: uncommitted,
version_source_increment: VersionField::None,
other_metadata: None,
};
if chosen.base.exact {
meta.commits_since_tag = None;
sv.build_metadata = meta;
return Ok(sv);
}
match eff.deployment_mode {
DeploymentMode::ManualDeployment => {
}
DeploymentMode::ContinuousDelivery => {
if sv.pre_release_tag.has_tag() {
let n = sv.pre_release_tag.number.unwrap_or(1);
sv.pre_release_tag.number = Some(n + commits - 1);
}
meta.commits_since_tag = None;
}
DeploymentMode::ContinuousDeployment => {
sv.pre_release_tag = PreReleaseTag::default();
meta.commits_since_tag = None;
}
}
sv.build_metadata = meta;
Ok(sv)
}
fn build_variables(
eff: &EffectiveConfiguration,
branch_name: &str,
head: &CommitInfo,
sv: &SemanticVersion,
source_semver: &SemanticVersion,
) -> Result<VersionVariables> {
let pre = &sv.pre_release_tag;
let pre_label = pre.name.clone();
let pre_number = pre.number;
let pre_tag_str = if pre.has_tag() {
pre.format(false)
} else {
String::new()
};
let with_dash = |s: &str| {
if s.is_empty() {
String::new()
} else {
format!("-{s}")
}
};
let major_minor_patch = sv.major_minor_patch();
let sem_ver = sv.to_string();
let commits = sv.build_metadata.version_source_distance;
let full_build_meta = sv.build_metadata.format_full();
let full_sem_ver = match sv.build_metadata.commits_since_tag {
Some(n) => format!("{sem_ver}+{n}"),
None => sem_ver.clone(),
};
let weighted = Some(match pre_number {
Some(n) => n + eff.pre_release_weight,
None => eff.tag_pre_release_weight,
});
let assembly_sem_ver = assembly_version(sv, eff.assembly_versioning_scheme);
let assembly_sem_file_ver = assembly_version(sv, eff.assembly_file_versioning_scheme);
let informational = if full_build_meta.is_empty() {
sem_ver.clone()
} else {
format!("{sem_ver}+{full_build_meta}")
};
let escaped_branch = Regex::new(r"[^a-zA-Z0-9-]")
.unwrap()
.replace_all(branch_name, "-")
.into_owned();
let date_fmt = dotnet_date_format_to_strftime(&eff.commit_date_format);
let commit_date = head.when.naive_utc().format(&date_fmt).to_string();
let mut vars = VersionVariables {
major: sv.major as u32,
minor: sv.minor as u32,
patch: sv.patch as u32,
pre_release_tag: pre_tag_str.clone(),
pre_release_tag_with_dash: with_dash(&pre_tag_str),
pre_release_label: pre_label.clone(),
pre_release_label_with_dash: with_dash(&pre_label),
pre_release_number: pre_number,
weighted_pre_release_number: weighted,
build_meta_data: sv.build_metadata.commits_since_tag,
full_build_meta_data: full_build_meta,
major_minor_patch,
sem_ver,
full_sem_ver,
assembly_sem_ver,
assembly_sem_file_ver,
informational_version: informational,
branch_name: branch_name.to_string(),
escaped_branch_name: escaped_branch,
sha: head.sha.clone(),
short_sha: head.short_sha.clone(),
version_source_distance: Some(commits),
version_source_increment: sv
.build_metadata
.version_source_increment
.as_str()
.to_string(),
version_source_sem_ver: source_semver.to_string(),
version_source_sha: sv
.build_metadata
.version_source_sha
.clone()
.unwrap_or_default(),
commits_since_version_source: Some(commits),
commit_date,
uncommitted_changes: sv.build_metadata.uncommitted_changes,
};
let ctx = vars.to_map();
if let Some(fmt) = &eff.assembly_versioning_format {
vars.assembly_sem_ver = render_template(fmt, &ctx)?;
}
if let Some(fmt) = &eff.assembly_file_versioning_format {
vars.assembly_sem_file_ver = render_template(fmt, &ctx)?;
}
vars.informational_version = render_template(&eff.assembly_informational_format, &ctx)?;
Ok(vars)
}
fn render_template(fmt: &str, ctx: &std::collections::BTreeMap<String, String>) -> Result<String> {
let re = Regex::new(r"\{(?<t>[A-Za-z0-9_:]+)\}").unwrap();
let mut unknown: Option<String> = None;
let out = re
.replace_all(fmt, |c: ®ex::Captures| {
let t = &c["t"];
if let Some(env_var) = t.strip_prefix("env:") {
std::env::var(env_var).unwrap_or_default()
} else if let Some(v) = ctx.get(t) {
v.clone()
} else {
if unknown.is_none() {
unknown = Some(t.to_string());
}
String::new()
}
})
.into_owned();
match unknown {
Some(t) => Err(anyhow::anyhow!(
"Unknown template token '{{{t}}}' in format string"
)),
None => Ok(out),
}
}
fn assembly_version(sv: &SemanticVersion, scheme: VersioningScheme) -> String {
let pre = sv.pre_release_tag.number.unwrap_or(0);
match scheme {
VersioningScheme::Major => format!("{}.0.0.0", sv.major),
VersioningScheme::MajorMinor => format!("{}.{}.0.0", sv.major, sv.minor),
VersioningScheme::MajorMinorPatch => {
format!("{}.{}.{}.0", sv.major, sv.minor, sv.patch)
}
VersioningScheme::MajorMinorPatchTag => {
format!("{}.{}.{}.{}", sv.major, sv.minor, sv.patch, pre)
}
VersioningScheme::None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::defaults;
fn default_eff() -> EffectiveConfiguration {
let cfg = defaults::gitflow();
EffectiveConfiguration::resolve(&cfg, "main")
}
#[test]
fn validate_config_regexes_rejects_bad_patterns() {
let eff = default_eff();
assert!(validate_config_regexes(&eff).is_ok());
let mut bad_prefix = default_eff();
bad_prefix.tag_prefix = "(unclosed".to_string();
assert!(validate_config_regexes(&bad_prefix).is_err());
let mut bad_bump = default_eff();
bad_bump.major_bump_message = "[invalid".to_string();
assert!(validate_config_regexes(&bad_bump).is_err());
let mut disabled = default_eff();
disabled.major_bump_message = "[invalid".to_string();
disabled.commit_message_incrementing = CommitMessageIncrementMode::Disabled;
assert!(validate_config_regexes(&disabled).is_ok());
}
#[test]
fn render_template_errors_on_unknown_token() {
let mut ctx = std::collections::BTreeMap::new();
ctx.insert("Major".to_string(), "1".to_string());
assert_eq!(render_template("v{Major}", &ctx).unwrap(), "v1");
assert!(render_template("{env:GV_NO_SUCH_VAR}", &ctx).is_ok());
assert!(render_template("{Bogus}", &ctx).is_err());
}
#[test]
fn parse_ignore_date_formats() {
let dt = parse_ignore_date("2021-06-15T12:00:00").unwrap();
assert!(dt.to_rfc3339().starts_with("2021-06-15"));
let dt2 = parse_ignore_date("2021-06-15").unwrap();
assert!(dt2.to_rfc3339().starts_with("2021-06-15"));
let dt3 = parse_ignore_date("2021-06-15 12:00:00").unwrap();
assert!(dt3.to_rfc3339().starts_with("2021-06-15"));
assert!(parse_ignore_date("not-a-date").is_none());
}
#[test]
fn ignore_set_sha_prefix_match() {
let full_sha = "abcdef1234567890abcdef1234567890abcdef12";
let prefix = "abcdef1"; let mut set = IgnoreSet::default();
set.shas.insert(prefix.to_lowercase());
let when = chrono::Utc::now().fixed_offset();
assert!(set.is_ignored(full_sha, &when));
let mut set2 = IgnoreSet::default();
set2.shas.insert("abcdef".to_lowercase()); assert!(!set2.is_ignored(full_sha, &when));
}
#[test]
fn ignore_set_before_date() {
let past = parse_ignore_date("2020-01-01").unwrap();
let set = IgnoreSet {
before: Some(parse_ignore_date("2021-01-01").unwrap()),
..Default::default()
};
assert!(set.is_ignored("anysha", &past));
let future = parse_ignore_date("2022-01-01").unwrap();
assert!(!set.is_ignored("anysha", &future));
}
#[test]
fn strategy_to_field_all_variants() {
assert_eq!(
strategy_to_field(IncrementStrategy::Major),
VersionField::Major
);
assert_eq!(
strategy_to_field(IncrementStrategy::Minor),
VersionField::Minor
);
assert_eq!(
strategy_to_field(IncrementStrategy::Patch),
VersionField::Patch
);
assert_eq!(
strategy_to_field(IncrementStrategy::None),
VersionField::None
);
assert_eq!(
strategy_to_field(IncrementStrategy::Inherit),
VersionField::None
);
}
#[test]
fn increment_from_message_all_levels() {
let eff = default_eff();
assert_eq!(
increment_from_message("big change\n+semver: major", &eff),
Some(VersionField::Major)
);
assert_eq!(
increment_from_message("new feature\n+semver: minor", &eff),
Some(VersionField::Minor)
);
assert_eq!(
increment_from_message("small fix\n+semver: patch", &eff),
Some(VersionField::Patch)
);
assert_eq!(
increment_from_message("chore\n+semver: none", &eff),
Some(VersionField::None)
);
assert_eq!(
increment_from_message("+semver: skip", &eff),
Some(VersionField::None)
);
assert_eq!(increment_from_message("ordinary commit", &eff), None);
}
#[test]
fn increment_from_message_breaking_alias() {
let eff = default_eff();
assert_eq!(
increment_from_message("+semver: breaking", &eff),
Some(VersionField::Major)
);
assert_eq!(
increment_from_message("+semver: feature", &eff),
Some(VersionField::Minor)
);
assert_eq!(
increment_from_message("+semver: fix", &eff),
Some(VersionField::Patch)
);
}
#[test]
fn ignore_set_filter_empty_shortcircuit() {
use crate::git::CommitInfo;
let set = IgnoreSet::default();
let commits = vec![CommitInfo {
sha: "abc".into(),
short_sha: "abc".into(),
message: "msg".into(),
when: chrono::Utc::now().fixed_offset(),
parent_count: 0,
parents: vec![],
}];
assert!(set.shas.is_empty() && set.before.is_none() && set.paths.is_empty());
let _ = commits; }
}