monochange 0.7.0

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

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

use crate::git_support;

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

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

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

pub(crate) async 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)
		.await
		.map(Some)
}

fn effective_release_branch_policy(
	stable: &ProviderReleaseSettings,
	prerelease: &PrereleaseConfiguration,
) -> ProviderReleaseSettings {
	let mut policy = stable.clone();
	if prerelease.enabled && !prerelease.branches.is_empty() {
		policy.branches.clone_from(&prerelease.branches);
	}
	policy
}

pub(crate) async 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).await?;
	let branch_refs = candidate_release_branch_refs(root, &policy.branches).await?;

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

	let mut message = String::new();
	let _ = write!(
		message,
		"release ref `{ref_name}` resolves to commit {}, which is not reachable from any configured release branch pattern [",
		crate::short_commit_sha(&commit)
	);
	write_comma_separated(&mut message, policy.branches.iter().map(String::as_str));
	message.push_str("]; matching branch refs: ");
	write_branch_ref_names(&mut message, &branch_refs);

	Err(MonochangeError::Config(message))
}

fn write_branch_ref_names(output: &mut String, branch_refs: &[BranchRef]) {
	if branch_refs.is_empty() {
		output.push_str("none found");
		return;
	}

	write_comma_separated(
		output,
		branch_refs
			.iter()
			.map(|branch| branch.display_name.as_str()),
	);
}

fn write_comma_separated<'a>(output: &mut String, values: impl IntoIterator<Item = &'a str>) {
	let mut is_first = true;
	for value in values {
		if is_first {
			is_first = false;
		} else {
			output.push_str(", ");
		}
		output.push_str(value);
	}
}

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

async 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<_>>>()?;

	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",
	)
	.await?;

	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;