use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use clap::ValueEnum;
use crate::errors::{Error, Result};
use crate::io::files;
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DiffBackend {
#[default]
Git,
}
impl DiffBackend {
pub fn id(self) -> &'static str {
match self {
DiffBackend::Git => "git",
}
}
}
pub const AMBIENT_REPOSITORY_VARS: [&str; 7] = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_COMMON_DIR",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_NAMESPACE",
];
pub fn git_command(program: &str, dir: &Path) -> Command {
let mut command = Command::new(program);
command.arg("-C").arg(dir);
for name in AMBIENT_REPOSITORY_VARS {
command.env_remove(name);
}
command
}
pub trait DiffProvider {
fn diffs(&self, root: &Path, files: &[PathBuf]) -> Result<BTreeMap<PathBuf, String>>;
}
pub fn provider(backend: DiffBackend, base: Option<String>) -> Box<dyn DiffProvider> {
match backend {
DiffBackend::Git => Box::new(GitDiff::with_base(base)),
}
}
pub struct GitDiff {
base: Option<String>,
git_bin: String,
}
impl Default for GitDiff {
fn default() -> Self {
GitDiff::new()
}
}
impl GitDiff {
pub fn new() -> Self {
GitDiff::with_base(None)
}
pub fn with_base(base: Option<String>) -> Self {
GitDiff {
base,
git_bin: "git".to_string(),
}
}
fn git(&self, root: &Path, args: &[&str]) -> Result<String> {
let output = git_command(&self.git_bin, root)
.args(args)
.output()
.map_err(|e| {
let message = if e.kind() == std::io::ErrorKind::NotFound {
"git not found on PATH; install git or choose another --diff backend"
.to_string()
} else {
format!("running git: {e}")
};
Error::Diff {
backend: DiffBackend::Git.id().to_string(),
message,
}
})?;
if !output.status.success() {
return Err(Error::Diff {
backend: DiffBackend::Git.id().to_string(),
message: format!(
"`git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
),
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn rev_exists(&self, root: &Path, rev: &str) -> bool {
git_command(&self.git_bin, root)
.args(["rev-parse", "--verify", "--quiet", rev])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn merge_base(&self, root: &Path, rev: &str) -> Option<String> {
let output = git_command(&self.git_bin, root)
.args(["merge-base", rev, "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let mb = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!mb.is_empty()).then_some(mb)
}
}
impl DiffProvider for GitDiff {
fn diffs(&self, root: &Path, files: &[PathBuf]) -> Result<BTreeMap<PathBuf, String>> {
let inside = self.git(root, &["rev-parse", "--is-inside-work-tree"])?;
if inside.trim() != "true" {
return Err(Error::Diff {
backend: DiffBackend::Git.id().to_string(),
message: format!("{} is not inside a git work tree", root.display()),
});
}
let base_arg = match &self.base {
Some(rev) if rev.contains("..") => rev.clone(),
Some(rev) => self.merge_base(root, rev).unwrap_or_else(|| rev.clone()),
None if self.rev_exists(root, "HEAD") => "HEAD".to_string(),
None => "--cached".to_string(),
};
let mut out = BTreeMap::new();
for file in files {
let rel = files::to_slash(file);
let args = ["diff", "--no-color", base_arg.as_str(), "--", &rel];
let diff = self.git(root, &args)?;
if !diff.trim().is_empty() {
out.insert(file.clone(), diff);
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn git(dir: &Path, args: &[&str]) {
let ok = git_command("git", dir)
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
}
fn init_repo(dir: &Path) {
git(dir, &["init", "-q"]);
git(dir, &["config", "user.email", "t@t.t"]);
git(dir, &["config", "user.name", "t"]);
git(dir, &["checkout", "-q", "-b", "main"]);
}
#[test]
fn backend_default_is_git() {
assert_eq!(DiffBackend::default(), DiffBackend::Git);
assert_eq!(DiffBackend::Git.id(), "git");
}
#[test]
fn git_diff_reports_only_changed_files() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
fs::write(root.join("b.rs"), "fn b() {}\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", "init"]);
fs::write(root.join("a.rs"), "fn a() { todo!() }\n").unwrap();
let provider = GitDiff::new();
let diffs = provider
.diffs(root, &[PathBuf::from("a.rs"), PathBuf::from("b.rs")])
.unwrap();
assert!(diffs.contains_key(Path::new("a.rs")), "got {diffs:?}");
assert!(diffs[Path::new("a.rs")].contains("todo!()"));
assert!(!diffs.contains_key(Path::new("b.rs")), "got {diffs:?}");
}
#[test]
fn unborn_head_falls_back_to_worktree_diff() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
git(root, &["add", "a.rs"]);
let diffs = GitDiff::new()
.diffs(root, &[PathBuf::from("a.rs")])
.unwrap();
assert!(diffs.contains_key(Path::new("a.rs")), "got {diffs:?}");
}
#[test]
fn non_repo_is_a_clear_error() {
let dir = tempdir().unwrap();
let err = GitDiff::new()
.diffs(dir.path(), &[PathBuf::from("a.rs")])
.unwrap_err();
assert!(matches!(err, Error::Diff { .. }), "got {err:?}");
assert!(err.to_string().contains("diff (git)"));
}
#[test]
fn bare_repo_is_not_a_work_tree() {
let dir = tempdir().unwrap();
git(dir.path(), &["init", "-q", "--bare"]);
let err = GitDiff::new()
.diffs(dir.path(), &[PathBuf::from("a.rs")])
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not inside a git work tree"), "got {msg}");
}
#[test]
fn missing_git_binary_is_a_clear_error() {
let dir = tempdir().unwrap();
let provider = GitDiff {
base: None,
git_bin: "definitely-not-a-real-git-xyz".into(),
};
let err = provider
.diffs(dir.path(), &[PathBuf::from("a.rs")])
.unwrap_err();
assert!(err.to_string().contains("git not found"), "got {err}");
}
#[test]
fn provider_dispatches_git_backend() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
git(root, &["add", "a.rs"]);
git(root, &["commit", "-q", "-m", "init"]);
fs::write(root.join("a.rs"), "fn a() { 1; }\n").unwrap();
let diffs = provider(DiffBackend::Git, None)
.diffs(root, &[PathBuf::from("a.rs")])
.unwrap();
assert!(diffs.contains_key(Path::new("a.rs")));
}
#[test]
fn default_matches_new() {
let d = GitDiff::default();
assert_eq!(d.base, None);
assert_eq!(d.git_bin, "git");
}
#[test]
fn explicit_base_diffs_against_a_named_ref() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
git(root, &["add", "a.rs"]);
git(root, &["commit", "-q", "-m", "baseline"]);
git(root, &["checkout", "-q", "-b", "feature"]);
fs::write(root.join("a.rs"), "fn a() { feature(); }\n").unwrap();
git(root, &["commit", "-q", "-am", "feature change"]);
let none = GitDiff::new()
.diffs(root, &[PathBuf::from("a.rs")])
.unwrap();
assert!(none.is_empty(), "got {none:?}");
let vs_main = GitDiff::with_base(Some("main".into()))
.diffs(root, &[PathBuf::from("a.rs")])
.unwrap();
assert!(vs_main.contains_key(Path::new("a.rs")), "got {vs_main:?}");
assert!(vs_main[Path::new("a.rs")].contains("feature()"));
}
#[test]
fn explicit_base_accepts_a_range() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
git(root, &["add", "a.rs"]);
git(root, &["commit", "-q", "-m", "baseline"]);
git(root, &["checkout", "-q", "-b", "feature"]);
fs::write(root.join("a.rs"), "fn a() { ranged(); }\n").unwrap();
git(root, &["commit", "-q", "-am", "feature change"]);
let diffs = GitDiff::with_base(Some("main..HEAD".into()))
.diffs(root, &[PathBuf::from("a.rs")])
.unwrap();
assert!(diffs.contains_key(Path::new("a.rs")), "got {diffs:?}");
assert!(diffs[Path::new("a.rs")].contains("ranged()"));
}
#[test]
fn explicit_base_with_unknown_ref_is_a_clear_error() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
git(root, &["add", "a.rs"]);
git(root, &["commit", "-q", "-m", "baseline"]);
let err = GitDiff::with_base(Some("no-such-ref".into()))
.diffs(root, &[PathBuf::from("a.rs")])
.unwrap_err();
assert!(matches!(err, Error::Diff { .. }), "got {err:?}");
assert!(err.to_string().contains("diff (git)"), "got {err}");
}
#[test]
fn explicit_base_ignores_base_branch_drift_after_the_fork() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
fs::write(root.join("base_only.rs"), "fn base() {}\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", "fork point"]);
git(root, &["checkout", "-q", "-b", "feature"]);
fs::write(root.join("a.rs"), "fn a() { feature(); }\n").unwrap();
git(root, &["commit", "-q", "-am", "feature change"]);
git(root, &["checkout", "-q", "main"]);
fs::write(root.join("base_only.rs"), "fn base() { drifted(); }\n").unwrap();
git(root, &["commit", "-q", "-am", "base drift"]);
git(root, &["checkout", "-q", "feature"]);
let diffs = GitDiff::with_base(Some("main".into()))
.diffs(
root,
&[PathBuf::from("a.rs"), PathBuf::from("base_only.rs")],
)
.unwrap();
assert!(diffs.contains_key(Path::new("a.rs")), "got {diffs:?}");
assert!(diffs[Path::new("a.rs")].contains("feature()"));
assert!(
!diffs.contains_key(Path::new("base_only.rs")),
"base-branch drift leaked into the diff: {diffs:?}"
);
}
#[test]
fn explicit_two_dot_range_still_includes_base_drift() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
git(root, &["add", "a.rs"]);
git(root, &["commit", "-q", "-m", "baseline"]);
git(root, &["checkout", "-q", "-b", "feature"]);
fs::write(root.join("a.rs"), "fn a() { ranged(); }\n").unwrap();
git(root, &["commit", "-q", "-am", "feature change"]);
let diffs = GitDiff::with_base(Some("main..HEAD".into()))
.diffs(root, &[PathBuf::from("a.rs")])
.unwrap();
assert!(diffs.contains_key(Path::new("a.rs")), "got {diffs:?}");
assert!(diffs[Path::new("a.rs")].contains("ranged()"));
}
#[test]
fn unrelated_base_falls_back_to_two_dot_diff() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
git(root, &["add", "a.rs"]);
git(root, &["commit", "-q", "-m", "main baseline"]);
git(root, &["checkout", "-q", "--orphan", "orphan"]);
fs::write(root.join("a.rs"), "fn a() { orphan(); }\n").unwrap();
git(root, &["add", "a.rs"]);
git(root, &["commit", "-q", "-m", "orphan baseline"]);
let diffs = GitDiff::with_base(Some("main".into()))
.diffs(root, &[PathBuf::from("a.rs")])
.unwrap();
assert!(diffs.contains_key(Path::new("a.rs")), "got {diffs:?}");
}
}