use std::path::Path;
use std::process::Command;
pub fn capture_diff(workspace: &Path) -> anyhow::Result<String> {
let output = Command::new("git")
.args(["diff", "--no-color"])
.current_dir(workspace)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_PREFIX")
.output();
match output {
Ok(out) if out.status.success() => Ok(String::from_utf8_lossy(&out.stdout).to_string()),
Ok(out) => {
tracing::warn!(
stderr = %String::from_utf8_lossy(&out.stderr),
"git diff returned non-zero status"
);
Ok(String::new())
}
Err(e) => {
tracing::warn!(error = %e, "could not invoke git diff");
Ok(String::new())
}
}
}
pub fn is_empty_diff(diff: &str) -> bool {
diff.trim().is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as StdCommand;
fn init_git_repo(path: &Path) {
let run = |args: &[&str]| {
StdCommand::new("git")
.args(args)
.current_dir(path)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_PREFIX")
.output()
.expect("git command failed")
};
run(&["init", "-q"]);
run(&["config", "user.email", "test@test"]);
run(&["config", "user.name", "test"]);
}
#[test]
fn empty_diff_is_empty() {
assert!(is_empty_diff(""));
assert!(is_empty_diff(" \n\t"));
}
#[test]
fn nonempty_diff_is_not_empty() {
assert!(!is_empty_diff(
"--- a/foo\n+++ b/foo\n@@ -1,1 +1,1 @@\n-a\n+b\n"
));
}
#[test]
fn capture_diff_on_non_git_workspace_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let diff = capture_diff(tmp.path()).unwrap();
assert!(is_empty_diff(&diff));
}
#[test]
fn capture_diff_sees_unstaged_changes() {
let tmp = tempfile::tempdir().unwrap();
init_git_repo(tmp.path());
let file = tmp.path().join("hello.txt");
std::fs::write(&file, "before\n").unwrap();
StdCommand::new("git")
.args(["add", "hello.txt"])
.current_dir(tmp.path())
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_PREFIX")
.output()
.unwrap();
StdCommand::new("git")
.args(["commit", "-q", "-m", "init"])
.current_dir(tmp.path())
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_PREFIX")
.output()
.unwrap();
std::fs::write(&file, "after\n").unwrap();
let diff = capture_diff(tmp.path()).unwrap();
assert!(!is_empty_diff(&diff));
assert!(diff.contains("-before"));
assert!(diff.contains("+after"));
}
#[test]
fn capture_diff_clean_repo_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
init_git_repo(tmp.path());
let diff = capture_diff(tmp.path()).unwrap();
assert!(is_empty_diff(&diff));
}
#[test]
fn capture_diff_ignores_inherited_git_env() {
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
init_git_repo(tmp.path());
let prev_git_dir = std::env::var_os("GIT_DIR");
let prev_git_work_tree = std::env::var_os("GIT_WORK_TREE");
unsafe {
std::env::set_var("GIT_DIR", "/nonexistent/git/dir");
std::env::set_var("GIT_WORK_TREE", "/nonexistent/work/tree");
}
let diff = capture_diff(tmp.path()).unwrap();
unsafe {
match prev_git_dir {
Some(v) => std::env::set_var("GIT_DIR", v),
None => std::env::remove_var("GIT_DIR"),
}
match prev_git_work_tree {
Some(v) => std::env::set_var("GIT_WORK_TREE", v),
None => std::env::remove_var("GIT_WORK_TREE"),
}
}
assert!(
is_empty_diff(&diff),
"capture_diff must clear inherited GIT_DIR/GIT_WORK_TREE; got: {diff:?}"
);
}
}