use crate::utils::command_exists;
use semver::Version;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use super::adapters::parse_version;
use super::{
slugify_scope_name, unscoped_npm_package_name,
GitTagObservation, GitWorktreeState, VersionScope,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DirtyWorktreeAnalysis {
all_entries: Vec<String>,
pub(crate) safe_entries: Vec<String>,
pub(crate) risky_entries: Vec<String>,
}
pub(crate) fn analyze_dirty_worktree(entries: &[String]) -> DirtyWorktreeAnalysis {
let mut paths = Vec::new();
for entry in entries {
if let Some(path) = parse_git_status_path(entry) {
paths.push(path);
}
}
paths.sort();
paths.dedup();
let mut safe_entries = Vec::new();
let mut risky_entries = Vec::new();
for path in paths {
if is_safe_dirty_path(&path) {
safe_entries.push(path);
} else {
risky_entries.push(path);
}
}
DirtyWorktreeAnalysis {
all_entries: entries.to_vec(),
safe_entries,
risky_entries,
}
}
pub(crate) fn collect_git_versions(
project_root: &Path,
version_scope: &VersionScope,
) -> Result<Vec<GitTagObservation>, String> {
if !command_exists("git") {
return Err("Git is not installed; skipping git tag inspection.".to_string());
}
let output: std::process::Output = Command::new("git")
.current_dir(project_root)
.args(["tag", "--list"])
.output()
.map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err("`git tag --list` failed in the current directory.".to_string());
}
return Err(format!("`git tag --list` failed: {}", stderr));
}
Ok(parse_local_git_tag_output_for_scope(
&String::from_utf8_lossy(&output.stdout),
version_scope,
))
}
pub(crate) fn collect_remote_git_versions(
project_root: &Path,
remote: &str,
version_scope: &VersionScope,
) -> Result<Vec<GitTagObservation>, String> {
if !command_exists("git") {
return Err("Git is not installed; skipping remote tag inspection.".to_string());
}
let output: std::process::Output = Command::new("git")
.current_dir(project_root)
.args(["ls-remote", "--tags", remote])
.output()
.map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
if !output.status.success() {
let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!("`git ls-remote --tags {}` failed.", remote));
}
return Err(format!(
"`git ls-remote --tags {}` failed: {}",
remote, stderr
));
}
Ok(parse_remote_git_tag_output_for_scope(
&String::from_utf8_lossy(&output.stdout),
version_scope,
))
}
pub(crate) fn release_scope_slug(version_scope: &VersionScope, repo: &str) -> String {
match version_scope {
VersionScope::Repository => default_release_tag_slug(repo),
VersionScope::Crate { package_name, .. } => default_release_tag_slug(package_name),
VersionScope::Service { service_name, .. } => default_release_tag_slug(service_name),
}
}
pub(crate) fn release_version_core(version: &Version) -> String {
let mut core = Version::new(version.major, version.minor, version.patch);
core.pre = version.pre.clone();
core.to_string()
}
pub(crate) fn release_flag_segment(version: &Version) -> Option<&str> {
let flag = version.build.as_str();
if flag.is_empty() {
None
} else {
Some(flag)
}
}
pub(crate) fn scoped_package_release_tag_name(slug: &str, version: &Version) -> String {
let core = release_version_core(version);
if let Some(flag) = release_flag_segment(version) {
format!("{slug}-{core}-{flag}")
} else {
format!("{slug}-{core}")
}
}
pub(crate) fn canonical_release_tag_name(
version_scope: &VersionScope,
version: &Version,
repo: &str,
) -> String {
if matches!(version_scope, VersionScope::Repository) {
return format!("v{}", version);
}
scoped_package_release_tag_name(&release_scope_slug(version_scope, repo), version)
}
pub(crate) fn release_tag_name_for_project(
project_root: &Path,
version_scope: &VersionScope,
version: &Version,
repo_label: &str,
) -> String {
if super::scope::is_nested_repository_scope(project_root, version_scope) {
return scoped_package_release_tag_name(&default_release_tag_slug(repo_label), version);
}
canonical_release_tag_name(version_scope, version, repo_label)
}
pub(crate) fn canonical_release_title(
version_scope: &VersionScope,
version: &Version,
repo: &str,
) -> String {
let slug = release_scope_slug(version_scope, repo);
let core = release_version_core(version);
if let Some(flag) = release_flag_segment(version) {
format!("{core}-{flag}-{slug}")
} else {
format!("{core}-{slug}")
}
}
pub(crate) fn default_release_tag_name(
version_scope: &VersionScope,
version: &Version,
repo: &str,
) -> Result<String, String> {
Ok(canonical_release_tag_name(version_scope, version, repo))
}
pub(crate) fn default_release_title(
version_scope: &VersionScope,
version: &Version,
repo: &str,
) -> String {
canonical_release_title(version_scope, version, repo)
}
pub(crate) fn default_release_tag_slug(value: &str) -> String {
slugify_scope_name(unscoped_npm_package_name(value))
}
pub(crate) fn release_tag_family(tag_name: &str) -> String {
if tag_name.starts_with('v')
&& tag_name
.chars()
.nth(1)
.map(|ch| ch.is_ascii_digit())
.unwrap_or(false)
{
return "v".to_string();
}
let mut family = String::new();
for ch in tag_name.chars() {
if ch.is_ascii_digit() {
break;
}
family.push(ch);
}
family
}
pub(crate) fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
parse_canonical_scoped_release_tag_version(tag, family)
}
pub(crate) fn version_scope_tag_family(version_scope: &VersionScope, current_tag_name: &str) -> String {
match version_scope {
VersionScope::Repository => release_tag_family(current_tag_name),
VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
tag_prefix.clone()
}
}
}
pub(crate) const RELEASE_FLAG_SUFFIXES: &[&str] =
&["dev", "stable", "beta", "alpha", "nightly", "exp"];
pub(crate) fn parse_canonical_scoped_release_tag_version(
tag: &str,
family: &str,
) -> Option<Version> {
if family == "v" {
return parse_version(tag).ok();
}
let rest = tag.strip_prefix(family)?;
for flag in RELEASE_FLAG_SUFFIXES {
let suffix = format!("-{flag}");
let Some(core) = rest.strip_suffix(&suffix) else {
continue;
};
let Ok(mut version) = parse_version(core) else {
continue;
};
if version.build.is_empty() {
version.build = semver::BuildMetadata::new(flag).ok()?;
}
return Some(version);
}
parse_version(rest).ok()
}
pub(crate) fn list_release_family_tags(
project_root: &Path,
family: &str,
) -> Result<BTreeSet<String>, String> {
let pattern = format!("{family}*");
let mut tags = BTreeSet::new();
let local = run_git_command(project_root, &["tag", "--list", &pattern])?;
for line in local.lines() {
let tag = line.trim();
if !tag.is_empty() {
tags.insert(tag.to_string());
}
}
if command_exists("git") {
let remote = run_git_command(project_root, &["ls-remote", "--tags", "origin", &pattern]);
if let Ok(remote) = remote {
for line in remote.lines() {
let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
let tag = reference
.strip_prefix("refs/tags/")
.unwrap_or(reference)
.trim_end_matches("^{}")
.trim();
if !tag.is_empty() {
tags.insert(tag.to_string());
}
}
}
}
Ok(tags)
}
pub(crate) fn ensure_git_tag_available_for_log(project_root: &Path, tag: &str) -> Result<(), String> {
if git_tag_exists(project_root, tag)? {
return Ok(());
}
let fetch_ref = format!("refs/tags/{tag}:refs/tags/{tag}");
let output = Command::new("git")
.current_dir(project_root)
.args(["fetch", "origin", &fetch_ref])
.output()
.map_err(|e| format!("Failed to fetch tag `{}` from origin: {}", tag, e))?;
if output.status.success() || git_tag_exists(project_root, tag)? {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(format!(
"Release previous tag `{}` is not available locally and could not be fetched from origin{}",
tag,
if stderr.is_empty() {
".".to_string()
} else {
format!(": {stderr}")
}
))
}
pub(crate) fn git_current_branch(project_root: &Path) -> Result<Option<String>, String> {
let branch_name = run_git_command(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
if branch_name.is_empty() || branch_name == "HEAD" {
Ok(None)
} else {
Ok(Some(branch_name))
}
}
pub(crate) fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
Ok(output
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.map(|line| line.to_string())
.collect())
}
pub(crate) fn git_head_commitish(project_root: &Path) -> Result<String, String> {
let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
if commitish.is_empty() {
Err("Unable to resolve HEAD commit for release target.".to_string())
} else {
Ok(commitish)
}
}
pub(crate) fn git_local_branch_commit(
project_root: &Path,
branch_name: &str,
) -> Result<Option<String>, String> {
let output = Command::new("git")
.current_dir(project_root)
.args([
"rev-parse",
"--verify",
&format!("refs/heads/{}", branch_name),
])
.output()
.map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
if !output.status.success() {
return Ok(None);
}
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
if commit.is_empty() {
Ok(None)
} else {
Ok(Some(commit))
}
}
pub(crate) fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
let query: String = format!("refs/tags/{}", tag);
let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
Ok(!output.trim().is_empty())
}
pub(crate) fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
run_git_command(project_root, &["remote", "get-url", remote])
}
pub(crate) fn git_repository_root(dir: &Path) -> Option<PathBuf> {
if !command_exists("git") {
return None;
}
let output: std::process::Output = Command::new("git")
.current_dir(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
if root.is_empty() {
None
} else {
Some(PathBuf::from(root))
}
}
pub(crate) fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
let output = Command::new("git")
.current_dir(project_root)
.args(["show", &format!("HEAD:{}", relative)])
.output()
.map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!("{} is not present in HEAD", relative));
}
return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
}
String::from_utf8(output.stdout)
.map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
}
pub(crate) fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
let range: String = format!("{}..HEAD", tag);
run_git_command(project_root, &["rev-list", "--count", &range])
.ok()
.and_then(|raw| raw.trim().parse::<usize>().ok())
}
pub(crate) fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
Ok(!output.trim().is_empty())
}
pub(crate) fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
let mut versions: Vec<GitTagObservation> = by_version
.into_iter()
.map(|(version, mut raw_tags)| {
raw_tags.sort();
raw_tags.dedup();
GitTagObservation { version, raw_tags }
})
.collect();
versions.sort_by(|a, b| b.version.cmp(&a.version));
versions
}
pub(crate) fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
if !command_exists("git") {
return Ok(None);
}
let status_output: std::process::Output = Command::new("git")
.current_dir(project_root)
.args(["status", "--porcelain"])
.output()
.map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
if !status_output.status.success() {
return Ok(None);
}
let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
.lines()
.any(|line| !line.trim().is_empty());
let head_output: std::process::Output = Command::new("git")
.current_dir(project_root)
.args(["rev-parse", "HEAD"])
.output()
.map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
let head_commit: Option<String> = if head_output.status.success() {
let value: String = String::from_utf8_lossy(&head_output.stdout)
.trim()
.to_string();
if value.is_empty() {
None
} else {
Some(value)
}
} else {
None
};
Ok(Some(GitWorktreeState {
is_dirty,
head_commit,
}))
}
pub(crate) fn is_safe_dirty_path(path: &str) -> bool {
let normalized = path.replace('\\', "/");
let segments: Vec<&str> = normalized.split('/').collect();
if segments.first() == Some(&".xbp") {
return true;
}
if segments.contains(&"target") {
return true;
}
if segments.contains(&"__pycache__") {
return true;
}
if segments.contains(&"node_modules") || segments.contains(&".next") {
return true;
}
if normalized.ends_with(".pyc") || normalized.ends_with(".pyo") {
return true;
}
false
}
pub(crate) fn parse_git_status_path(line: &str) -> Option<String> {
let line = line.trim_end();
if line.len() < 3 {
return None;
}
let path_part = line.get(2..)?.trim_start();
if path_part.is_empty() {
return None;
}
if let Some((_old, new)) = path_part.split_once(" -> ") {
return Some(new.trim().replace('\\', "/"));
}
Some(path_part.replace('\\', "/"))
}
pub(crate) fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
for line in output.lines() {
let tag = line.trim();
if tag.is_empty() {
continue;
}
if let Ok(version) = parse_version(tag) {
by_version.entry(version).or_default().push(tag.to_string());
}
}
git_tag_map_to_vec(by_version)
}
pub(crate) fn parse_local_git_tag_output_for_scope(
output: &str,
version_scope: &VersionScope,
) -> Vec<GitTagObservation> {
match version_scope {
VersionScope::Repository => parse_local_git_tag_output(output),
VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
parse_scoped_git_tag_output(output, tag_prefix)
}
}
}
pub(crate) fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
for line in output.lines() {
let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
let tag = reference
.strip_prefix("refs/tags/")
.unwrap_or(reference)
.trim_end_matches("^{}")
.trim();
if tag.is_empty() {
continue;
}
if let Ok(version) = parse_version(tag) {
by_version.entry(version).or_default().push(tag.to_string());
}
}
git_tag_map_to_vec(by_version)
}
pub(crate) fn parse_remote_git_tag_output_for_scope(
output: &str,
version_scope: &VersionScope,
) -> Vec<GitTagObservation> {
match version_scope {
VersionScope::Repository => parse_remote_git_tag_output(output),
VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
for line in output.lines() {
let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
let tag = reference
.strip_prefix("refs/tags/")
.unwrap_or(reference)
.trim_end_matches("^{}")
.trim();
if tag.is_empty() {
continue;
}
if let Some(version) = parse_release_family_version(tag, tag_prefix) {
by_version.entry(version).or_default().push(tag.to_string());
}
}
git_tag_map_to_vec(by_version)
}
}
}
pub(crate) fn parse_scoped_git_tag_output(output: &str, tag_prefix: &str) -> Vec<GitTagObservation> {
let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
for line in output.lines() {
let tag = line.trim();
if tag.is_empty() {
continue;
}
if let Some(version) = parse_release_family_version(tag, tag_prefix) {
by_version.entry(version).or_default().push(tag.to_string());
}
}
git_tag_map_to_vec(by_version)
}
pub(crate) fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
let output: std::process::Output = Command::new("git")
.current_dir(project_root)
.args(args)
.output()
.map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
if !output.status.success() {
let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!(
"`git {}` failed with status {}",
args.join(" "),
output.status
));
}
return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}