use std::process::ExitCode;
use fallow_engine::changed_files::clear_ambient_git_env;
use crate::error::emit_error;
use super::AuditOptions;
pub struct DetectedBase {
pub git_ref: String,
pub description: String,
}
fn git_stdout(root: &std::path::Path, args: &[&str]) -> Option<String> {
let mut command = std::process::Command::new("git");
command.args(args).current_dir(root);
clear_ambient_git_env(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn git_ref_exists(root: &std::path::Path, git_ref: &str) -> bool {
git_stdout(root, &["rev-parse", "--verify", "--quiet", git_ref]).is_some()
}
fn git_upstream_ref(root: &std::path::Path) -> Option<String> {
git_stdout(
root,
&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
)
}
fn git_merge_base(root: &std::path::Path, a: &str, b: &str) -> Option<String> {
git_stdout(root, &["merge-base", a, b])
}
fn detect_remote_default_ref(root: &std::path::Path) -> Option<String> {
if let Some(full_ref) = git_stdout(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
&& let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
{
return Some(format!("origin/{branch}"));
}
for candidate in ["origin/main", "origin/master"] {
if git_ref_exists(root, candidate) {
return Some(candidate.to_string());
}
}
None
}
pub fn auto_detect_base_ref(root: &std::path::Path) -> Option<DetectedBase> {
if let Some(upstream) = git_upstream_ref(root) {
if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
return Some(DetectedBase {
git_ref: sha,
description: format!("merge-base with {upstream}"),
});
}
return Some(DetectedBase {
description: format!("{upstream} (tip)"),
git_ref: upstream,
});
}
if let Some(remote_ref) = detect_remote_default_ref(root) {
if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
return Some(DetectedBase {
git_ref: sha,
description: format!("merge-base with {remote_ref}"),
});
}
return Some(DetectedBase {
description: format!("{remote_ref} (tip)"),
git_ref: remote_ref,
});
}
for candidate in ["main", "master"] {
if git_ref_exists(root, candidate) {
return Some(DetectedBase {
git_ref: candidate.to_string(),
description: format!("local {candidate}"),
});
}
}
None
}
pub fn get_head_sha(root: &std::path::Path) -> Option<String> {
let mut command = std::process::Command::new("git");
command
.args(["rev-parse", "--short", "HEAD"])
.current_dir(root);
clear_ambient_git_env(&mut command);
let output = command.output().ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
pub fn parse_audit_base_override(raw: Option<String>) -> Option<String> {
let trimmed = raw?.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn audit_base_env_override() -> Option<String> {
parse_audit_base_override(std::env::var("FALLOW_AUDIT_BASE").ok())
}
pub fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<(String, Option<String>), ExitCode> {
if let Some(ref_str) = opts.changed_since {
return Ok((ref_str.to_string(), None));
}
if let Some(env_ref) = audit_base_env_override() {
if let Err(e) = crate::validate::validate_git_ref(&env_ref) {
return Err(emit_error(
&format!("FALLOW_AUDIT_BASE='{env_ref}' is not a valid git ref: {e}"),
2,
opts.output,
));
}
let description = format!("FALLOW_AUDIT_BASE={env_ref}");
return Ok((env_ref, Some(description)));
}
let Some(detected) = auto_detect_base_ref(opts.root) else {
return Err(emit_error(
"could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
2,
opts.output,
));
};
if let Err(e) = crate::validate::validate_git_ref(&detected.git_ref) {
return Err(emit_error(
&format!(
"auto-detected base ref '{}' is not a valid git ref: {e}",
detected.git_ref
),
2,
opts.output,
));
}
Ok((detected.git_ref, Some(detected.description)))
}