use std::path::{Path, PathBuf};
use std::process::Command;
pub fn load_diff(
root: &Path,
base: Option<&str>,
diff_file: Option<&PathBuf>,
) -> Result<String, String> {
if let Some(diff_file) = diff_file {
return std::fs::read_to_string(diff_file)
.map_err(|err| format!("failed to read diff file {}: {err}", diff_file.display()));
}
let owned;
let base: &str = if let Some(explicit) = base {
explicit
} else {
owned = resolve_default_base(root)?;
&owned
};
run_git_diff(root, &format!("{base}...HEAD"), &[])
}
fn resolve_default_base(root: &Path) -> Result<String, String> {
if let Some(remote_head) = git_symbolic_ref_quiet(root, "refs/remotes/origin/HEAD") {
if let Some(stripped) = remote_head.strip_prefix("refs/remotes/") {
let candidate = stripped.to_string();
if git_ref_exists(root, &candidate) {
return Ok(candidate);
}
}
}
for candidate in &["origin/main", "origin/master", "main", "master"] {
if git_ref_exists(root, candidate) {
return Ok((*candidate).to_string());
}
}
Err(
"could not resolve a default base (no origin/main, origin/master, or local main/master \
found). Pass `--base <ref>` to diff against a specific ref, or \
`--root . --format repo-exposure-md` for a full-repo scan."
.to_string(),
)
}
fn git_symbolic_ref_quiet(root: &Path, refname: &str) -> Option<String> {
let output = Command::new("git")
.args(["symbolic-ref", "--quiet", refname])
.current_dir(root)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn git_ref_exists(root: &Path, refname: &str) -> bool {
Command::new("git")
.args(["rev-parse", "--verify", "--quiet", refname])
.current_dir(root)
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
pub fn load_diff_range(root: &Path, base: &str, head: &str) -> Result<String, String> {
run_git_diff(
root,
&format!("{base}...{head}"),
&["--unified=0", "--no-ext-diff"],
)
}
fn run_git_diff(root: &Path, range: &str, extra_args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.arg("diff")
.args(extra_args)
.arg(range)
.current_dir(root)
.output()
.map_err(|err| format!("failed to run git diff: {err}"))?;
if !output.status.success() {
return Err(format!(
"git diff failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(test)]
#[expect(
clippy::expect_used,
reason = "Test asserts an expected error variant via `.expect_err(\"why\")`; the closure-style helper makes the expected failure mode part of the assertion message."
)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
#[test]
fn load_diff_from_file_returns_content() -> std::io::Result<()> {
let dir = std::env::temp_dir().join("ripr-load-diff-test");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let diff_file = dir.join("test.diff");
fs::write(&diff_file, "test content")?;
let result = load_diff(&dir, None, Some(&diff_file));
assert_eq!(result.as_deref(), Ok("test content"));
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn load_diff_with_missing_file_returns_error() -> std::io::Result<()> {
let result = load_diff(
&std::env::current_dir()?,
None,
Some(&PathBuf::from("/nonexistent/path/to/file")),
);
result.expect_err("expected diff load to fail for missing file");
Ok(())
}
fn init_git_repo(dir: &Path, branch: &str) -> std::io::Result<()> {
fs::create_dir_all(dir)?;
let status = Command::new("git")
.args(["init", "--initial-branch", branch])
.current_dir(dir)
.output()?
.status;
if !status.success() {
Command::new("git").arg("init").current_dir(dir).output()?;
Command::new("git")
.args(["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")])
.current_dir(dir)
.output()?;
}
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output()?;
fs::write(dir.join("README"), "init")?;
Command::new("git")
.args(["add", "."])
.current_dir(dir)
.output()?;
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(dir)
.output()?;
Ok(())
}
#[test]
fn resolve_default_base_uses_origin_master_when_symbolic_ref_points_there()
-> std::io::Result<()> {
let dir = std::env::temp_dir().join("ripr-resolve-base-origin-master");
let _ = fs::remove_dir_all(&dir);
init_git_repo(&dir, "master")?;
Command::new("git")
.args(["update-ref", "refs/remotes/origin/master", "HEAD"])
.current_dir(&dir)
.output()?;
Command::new("git")
.args([
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/master",
])
.current_dir(&dir)
.output()?;
let result = resolve_default_base(&dir);
assert_eq!(
result.as_deref(),
Ok("origin/master"),
"expected origin/master resolution via symbolic-ref"
);
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn resolve_default_base_uses_local_main_when_no_remote() -> std::io::Result<()> {
let dir = std::env::temp_dir().join("ripr-resolve-base-local-main");
let _ = fs::remove_dir_all(&dir);
init_git_repo(&dir, "main")?;
let refs_remote = dir.join(".git").join("refs").join("remotes");
let _ = fs::remove_dir_all(&refs_remote);
let result = resolve_default_base(&dir);
assert_eq!(
result.as_deref(),
Ok("main"),
"expected local main fallback when no remote"
);
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn resolve_default_base_returns_named_error_when_nothing_resolves() -> std::io::Result<()> {
let dir = std::env::temp_dir().join("ripr-resolve-base-no-base");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
Command::new("git").arg("init").current_dir(&dir).output()?;
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&dir)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&dir)
.output()?;
let result = resolve_default_base(&dir);
let err = result.expect_err("expected a named error when no base resolves");
assert!(
err.contains("could not resolve a default base"),
"expected named actionable message, got: {err}"
);
assert!(
err.contains("--base <ref>"),
"expected --base guidance in message, got: {err}"
);
assert!(
err.contains("--format repo-exposure-md"),
"expected --format repo-exposure-md guidance in message, got: {err}"
);
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn explicit_base_is_used_as_is_without_resolution() -> std::io::Result<()> {
let dir = std::env::temp_dir().join("ripr-explicit-base-no-subst");
let _ = fs::remove_dir_all(&dir);
init_git_repo(&dir, "main")?;
let result = load_diff(&dir, Some("nonexistent-branch-xyz"), None);
let err = result.expect_err("expected error for nonexistent explicit base");
assert!(
!err.contains("could not resolve a default base"),
"explicit base must not trigger auto-resolve fallback; got: {err}"
);
assert!(
err.contains("nonexistent-branch-xyz") || err.contains("git diff failed"),
"expected git-diff error for explicit bad base, got: {err}"
);
let _ = fs::remove_dir_all(&dir);
Ok(())
}
}