use crate::utils::command_exists;
use semver::Version;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
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) out_of_scope_entries: Vec<String>,
}
pub(crate) fn analyze_dirty_worktree(entries: &[String]) -> DirtyWorktreeAnalysis {
analyze_dirty_worktree_for_scope(entries, None)
}
pub(crate) fn analyze_dirty_worktree_for_scope(
entries: &[String],
in_scope: Option<&dyn Fn(&str) -> bool>,
) -> 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();
let mut out_of_scope_entries = Vec::new();
for path in paths {
if is_safe_dirty_path(&path) {
safe_entries.push(path);
continue;
}
if let Some(check) = in_scope {
if check(&path) {
risky_entries.push(path);
} else {
out_of_scope_entries.push(path);
}
} else {
risky_entries.push(path);
}
}
DirtyWorktreeAnalysis {
all_entries: entries.to_vec(),
safe_entries,
risky_entries,
out_of_scope_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> {
collect_remote_git_versions_cached(project_root, remote, version_scope, false)
}
pub(crate) fn collect_remote_git_versions_cached(
project_root: &Path,
remote: &str,
version_scope: &VersionScope,
force_refresh: bool,
) -> Result<Vec<GitTagObservation>, String> {
let raw = fetch_remote_tags_raw_cached(project_root, remote, force_refresh)?;
Ok(parse_remote_git_tag_output_for_scope(&raw, version_scope))
}
const REMOTE_TAGS_CACHE_TTL_SECS: u64 = 3600;
fn remote_tags_cache_key(project_root: &Path, remote: &str) -> String {
let root = fs::canonicalize(project_root)
.unwrap_or_else(|_| project_root.to_path_buf())
.to_string_lossy()
.replace('\\', "/")
.to_ascii_lowercase();
format!("{remote}::{root}")
}
fn remote_tags_disk_path(project_root: &Path, remote: &str) -> Option<PathBuf> {
let paths = crate::config::global_xbp_paths().ok()?;
let key = remote_tags_cache_key(project_root, remote);
let mut hasher = std::collections::hash_map::DefaultHasher::new();
use std::hash::{Hash, Hasher};
key.hash(&mut hasher);
let digest = format!("{:016x}", hasher.finish());
Some(
paths
.cache_dir
.join("versioning")
.join("remote-tags")
.join(format!("{digest}.txt")),
)
}
fn fetch_remote_tags_raw_cached(
project_root: &Path,
remote: &str,
force_refresh: bool,
) -> Result<String, String> {
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
static MEM: OnceLock<Mutex<BTreeMap<String, (Instant, String)>>> = OnceLock::new();
let cache_key = remote_tags_cache_key(project_root, remote);
let ttl = Duration::from_secs(REMOTE_TAGS_CACHE_TTL_SECS);
if !force_refresh {
if let Some(map) = MEM.get() {
if let Ok(guard) = map.lock() {
if let Some((fetched_at, raw)) = guard.get(&cache_key) {
if fetched_at.elapsed() < ttl {
return Ok(raw.clone());
}
}
}
}
if let Some(disk) = remote_tags_disk_path(project_root, remote) {
if let Ok(meta) = fs::metadata(&disk) {
if let Ok(modified) = meta.modified() {
let age = SystemTime::now()
.duration_since(modified)
.unwrap_or(Duration::MAX);
if age < ttl {
if let Ok(raw) = fs::read_to_string(&disk) {
let map = MEM.get_or_init(|| Mutex::new(BTreeMap::new()));
if let Ok(mut guard) = map.lock() {
guard.insert(cache_key.clone(), (Instant::now(), raw.clone()));
}
return Ok(raw);
}
}
}
}
}
}
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
));
}
let raw = String::from_utf8_lossy(&output.stdout).to_string();
let map = MEM.get_or_init(|| Mutex::new(BTreeMap::new()));
if let Ok(mut guard) = map.lock() {
guard.insert(cache_key, (Instant::now(), raw.clone()));
}
if let Some(disk) = remote_tags_disk_path(project_root, remote) {
if let Some(parent) = disk.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(&disk, &raw);
let _ = SystemTime::now().duration_since(UNIX_EPOCH);
}
Ok(raw)
}
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, _) = release_flag_and_build_number(version);
flag
}
pub(crate) fn release_flag_and_build_number(version: &Version) -> (Option<&str>, Option<&str>) {
let build = version.build.as_str();
if build.is_empty() {
return (None, None);
}
let mut parts = build.splitn(2, '.');
let first = parts.next().unwrap_or("");
let rest = parts.next();
if first.is_empty() {
return (None, None);
}
if RELEASE_FLAG_SUFFIXES.iter().any(|flag| *flag == first) {
return (Some(first), rest.filter(|s| !s.is_empty()));
}
if first.chars().all(|c| c.is_ascii_digit()) && rest.is_none() {
return (None, Some(first));
}
(Some(first), rest.filter(|s| !s.is_empty()))
}
pub(crate) fn scoped_package_release_tag_name(slug: &str, version: &Version) -> String {
let core = release_version_core(version);
let mut tag = format!("{slug}-{core}");
let (flag, build_number) = release_flag_and_build_number(version);
if let Some(flag) = flag {
tag.push('+');
tag.push_str(flag);
}
if let Some(build_number) = build_number {
tag.push('.');
tag.push_str(build_number);
}
tag
}
pub(crate) fn canonical_release_tag_name(
version_scope: &VersionScope,
version: &Version,
repo: &str,
) -> String {
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)?;
if let Some((core, build_meta)) = rest.split_once('+') {
let mut version = parse_version(core).ok()?;
if !build_meta.is_empty() {
version.build = semver::BuildMetadata::new(build_meta).ok()?;
}
return Some(version);
}
if let Some((core, num)) = rest.rsplit_once('.') {
if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) {
if let Ok(mut version) = parse_version(core) {
if version.pre.is_empty() && version.build.is_empty() {
version.build = semver::BuildMetadata::new(num).ok()?;
return Some(version);
}
}
}
}
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))
}
}
fn normalize_git_path(path: &str) -> String {
path.replace('\\', "/")
.trim_start_matches("./")
.trim_start_matches('/')
.to_string()
}
fn path_relative_to_base(base: &Path, path: &Path) -> Option<String> {
let root = base
.to_string_lossy()
.replace('\\', "/")
.trim_end_matches('/')
.to_string();
let full = path.to_string_lossy().replace('\\', "/");
if full.len() < root.len() {
return None;
}
let (prefix, suffix) = full.split_at(root.len());
if !prefix.eq_ignore_ascii_case(&root) {
return None;
}
if suffix.is_empty() {
return Some(String::new());
}
if !suffix.starts_with('/') {
return None;
}
Some(suffix.trim_start_matches('/').to_string())
}
pub(crate) fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
let relative_norm = normalize_git_path(relative);
let mut candidates: Vec<String> = Vec::new();
if let Some(git_root) = git_repository_root(project_root) {
let absolute = project_root.join(&relative_norm);
if let Some(from_git_root) = path_relative_to_base(&git_root, &absolute) {
if !from_git_root.is_empty() {
candidates.push(from_git_root);
}
}
candidates.push(format!("./{relative_norm}"));
}
candidates.push(relative_norm.clone());
candidates.sort();
candidates.dedup();
let mut last_error = format!("{relative_norm} is not present in HEAD");
for path in candidates {
let output = Command::new("git")
.current_dir(project_root)
.args(["show", &format!("HEAD:{path}")])
.output()
.map_err(|e| format!("Failed to read {relative_norm} from HEAD: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
last_error = format!("Failed to read {relative_norm} from HEAD: {stderr}");
}
continue;
}
return String::from_utf8(output.stdout)
.map_err(|e| format!("{relative_norm} in HEAD is not valid UTF-8: {e}"));
}
Err(last_error)
}
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()
.filter(|line| !line.trim().is_empty())
.any(|line| {
!crate::commands::deploy_engine::history_ext::porcelain_line_is_xbp_ledger_noise(line)
});
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 normalized == "dist-workspace.toml" || normalized.ends_with("/dist-workspace.toml") {
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 segments.contains(&".wrangler") || segments.contains(&".wrangler-dist") {
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())
}