monochange 0.5.1

Manage versions and releases for your multiplatform, multilanguage monorepo
Documentation
use std::path::Path;

use glob::Pattern;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::ProviderReleaseSettings;
use monochange_core::SourceConfiguration;
use serde::Serialize;

use crate::git_support;

#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReleaseBranchVerificationReport {
	pub ref_name: String,
	pub commit: String,
	pub allowed_branches: Vec<String>,
	pub matched_branch: String,
}

pub(crate) fn verify_release_ref_for_tags(
	root: &Path,
	source: Option<&SourceConfiguration>,
	ref_name: &str,
) -> MonochangeResult<Option<ReleaseBranchVerificationReport>> {
	let Some(source) = source else {
		return Ok(None);
	};
	if !source.releases.enforce_for_tags {
		return Ok(None);
	}
	verify_release_ref(root, &source.releases, ref_name).map(Some)
}

pub(crate) fn verify_release_ref_for_publish(
	root: &Path,
	source: Option<&SourceConfiguration>,
	ref_name: &str,
) -> MonochangeResult<Option<ReleaseBranchVerificationReport>> {
	let Some(source) = source else {
		return Ok(None);
	};
	if !source.releases.enforce_for_publish {
		return Ok(None);
	}
	verify_release_ref(root, &source.releases, ref_name).map(Some)
}

pub(crate) fn verify_release_ref_for_commit(
	root: &Path,
	source: Option<&SourceConfiguration>,
	ref_name: &str,
) -> MonochangeResult<Option<ReleaseBranchVerificationReport>> {
	let Some(source) = source else {
		return Ok(None);
	};
	if !source.releases.enforce_for_commit {
		return Ok(None);
	}
	verify_release_ref(root, &source.releases, ref_name).map(Some)
}

pub(crate) fn verify_release_ref(
	root: &Path,
	policy: &ProviderReleaseSettings,
	ref_name: &str,
) -> MonochangeResult<ReleaseBranchVerificationReport> {
	if policy.branches.is_empty() {
		return Err(MonochangeError::Config(
			"[source.releases].branches must contain at least one release branch pattern"
				.to_string(),
		));
	}

	let commit = git_support::resolve_git_commit_ref(root, ref_name)?;
	let branch_refs = candidate_release_branch_refs(root, &policy.branches)?;

	for branch_ref in &branch_refs {
		if git_support::git_is_ancestor(root, &commit, &branch_ref.ref_name)? {
			return Ok(ReleaseBranchVerificationReport {
				ref_name: ref_name.to_string(),
				commit,
				allowed_branches: policy.branches.clone(),
				matched_branch: branch_ref.display_name.clone(),
			});
		}
	}

	let available = branch_refs
		.iter()
		.map(|branch| branch.display_name.as_str())
		.collect::<Vec<_>>()
		.join(", ");
	let available = if available.is_empty() {
		"none found".to_string()
	} else {
		available
	};

	Err(MonochangeError::Config(format!(
		"release ref `{ref_name}` resolves to commit {}, which is not reachable from any configured release branch pattern [{}]; matching branch refs: {available}",
		crate::short_commit_sha(&commit),
		policy.branches.join(", ")
	)))
}

#[derive(Debug, Clone, Eq, PartialEq)]
struct BranchRef {
	ref_name: String,
	display_name: String,
}

fn candidate_release_branch_refs(
	root: &Path,
	patterns: &[String],
) -> MonochangeResult<Vec<BranchRef>> {
	let compiled = patterns
		.iter()
		.map(|pattern| {
			Pattern::new(pattern).map_err(|error| {
				MonochangeError::Config(format!(
					"invalid [source.releases].branches pattern `{pattern}`: {error}"
				))
			})
		})
		.collect::<MonochangeResult<Vec<_>>>()?;

	#[rustfmt::skip]
	let output = git_support::run_git_capture(root, &["for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes"], "failed to list git branches for release branch verification")?;

	let mut branches = Vec::new();
	for (ref_name, display_name) in output
		.lines()
		.filter(|line| !line.trim().is_empty())
		.filter_map(|ref_name| {
			display_branch_name(ref_name).map(|display_name| (ref_name, display_name))
		}) {
		if branch_matches(&compiled, ref_name, &display_name) {
			branches.push(BranchRef {
				ref_name: ref_name.to_string(),
				display_name,
			});
		}
	}

	Ok(branches)
}

fn display_branch_name(ref_name: &str) -> Option<String> {
	if let Some(local) = ref_name.strip_prefix("refs/heads/") {
		return Some(local.to_string());
	}
	let remote = ref_name.strip_prefix("refs/remotes/")?;
	if remote.ends_with("/HEAD") {
		return None;
	}
	Some(remote.to_string())
}

fn branch_matches(patterns: &[Pattern], ref_name: &str, display_name: &str) -> bool {
	let remote_stripped = display_name.split_once('/').map(|(_, branch)| branch);
	patterns.iter().any(|pattern| {
		pattern.matches(display_name)
			|| remote_stripped.is_some_and(|branch| pattern.matches(branch))
			|| pattern.matches(ref_name)
	})
}

#[cfg(test)]
#[path = "__tests__/release_branch_policy_tests.rs"]
mod tests;