use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum GitError {
#[error("'{path}' is not inside a git repository (--diff requires one)")]
NotARepository { path: String },
#[error("failed to run git: {source}")]
Spawn {
#[source]
source: std::io::Error,
},
#[error("unknown git ref '{base}': {stderr}")]
BadRef { base: String, stderr: String },
#[error("git {args} failed: {stderr}")]
Command { args: String, stderr: String },
}
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
const DIFF_FILTER: &str = "--diff-filter=ACMR";
fn run_git(dir: &Path, args: &[&str]) -> Result<Output, GitError> {
Command::new("git")
.current_dir(dir)
.args(args)
.output()
.map_err(|source| GitError::Spawn { source })
}
fn stderr_of(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).trim().to_string()
}
pub fn repo_root(dir: &Path) -> Result<PathBuf, GitError> {
let output = run_git(dir, &["rev-parse", "--show-toplevel"])?;
if !output.status.success() {
return Err(GitError::NotARepository {
path: dir.display().to_string(),
});
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(PathBuf::from(root))
}
pub fn changed_files(dir: &Path, base: Option<&str>) -> Result<Vec<PathBuf>, GitError> {
let root = repo_root(dir)?;
if let Some(b) = base {
let probe = run_git(
dir,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{b}^{{commit}}"),
],
)?;
if !probe.status.success() {
let stderr = stderr_of(&probe);
return Err(GitError::BadRef {
base: b.to_string(),
stderr: if stderr.is_empty() {
"no such ref".to_string()
} else {
stderr
},
});
}
}
let rev = match base {
Some(b) => format!("{b}...HEAD"),
None => {
let head = run_git(dir, &["rev-parse", "--verify", "--quiet", "HEAD"])?;
if head.status.success() {
"HEAD".to_string()
} else {
EMPTY_TREE.to_string()
}
}
};
let output = run_git(dir, &["diff", "--name-only", "-z", DIFF_FILTER, &rev])?;
if !output.status.success() {
return Err(GitError::Command {
args: format!("diff --name-only {rev}"),
stderr: stderr_of(&output),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut files: Vec<PathBuf> = stdout
.split('\0')
.filter(|name| !name.is_empty())
.map(|name| root.join(name))
.filter(|path| path.is_file())
.collect();
files.sort();
files.dedup();
Ok(files)
}
pub fn dirty_files(dir: &Path) -> Result<Vec<PathBuf>, GitError> {
let root = repo_root(dir)?;
let output = run_git(dir, &["status", "--porcelain", "-z"])?;
if !output.status.success() {
return Err(GitError::Command {
args: "status --porcelain".to_string(),
stderr: stderr_of(&output),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut fields = stdout.split('\0').filter(|f| !f.is_empty()).peekable();
let mut files = Vec::new();
while let Some(entry) = fields.next() {
if entry.len() < 3 {
continue;
}
let status = &entry[..2];
let path = &entry[3..];
if status.starts_with('R') {
fields.next();
}
if status.contains('D') {
continue;
}
files.push(root.join(path));
}
files.sort();
files.dedup();
Ok(files)
}
#[cfg(test)]
mod tests {
use std::fs::{create_dir_all, write};
use tempfile::tempdir;
use super::*;
fn git(dir: &Path, args: &[&str]) -> Output {
let output = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
output
}
fn init_repo(dir: &Path) {
git(dir, &["init", "-q", "-b", "main"]);
git(dir, &["config", "user.email", "test@example.com"]);
git(dir, &["config", "user.name", "Test"]);
git(dir, &["config", "commit.gpgsign", "false"]);
}
fn commit(dir: &Path, msg: &str) {
git(dir, &["add", "-A"]);
git(dir, &["commit", "-q", "-m", msg]);
}
fn names(files: &[PathBuf]) -> Vec<String> {
files
.iter()
.filter_map(|p| p.file_name().and_then(|n| n.to_str()))
.map(String::from)
.collect()
}
#[test]
fn not_a_git_repo_is_an_error() {
let dir = tempdir().unwrap();
match changed_files(dir.path(), None) {
Err(GitError::NotARepository { .. }) => {}
other => panic!("expected NotARepository, got {other:?}"),
}
}
#[test]
fn staged_and_unstaged_files_are_reported() {
let dir = tempdir().unwrap();
init_repo(dir.path());
write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
commit(dir.path(), "init");
write(dir.path().join("a.rs"), "fn a() { let x = 1; }\n").unwrap();
write(dir.path().join("b.rs"), "fn b() {}\n").unwrap();
git(dir.path(), &["add", "b.rs"]);
let files = changed_files(dir.path(), None).unwrap();
assert!(files.iter().all(|p| p.is_absolute()), "{files:?}");
let names = names(&files);
assert!(names.contains(&"a.rs".to_string()), "{names:?}");
assert!(names.contains(&"b.rs".to_string()), "{names:?}");
}
#[test]
fn deleted_file_is_excluded() {
let dir = tempdir().unwrap();
init_repo(dir.path());
write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
write(dir.path().join("b.rs"), "fn b() {}\n").unwrap();
commit(dir.path(), "init");
git(dir.path(), &["rm", "-q", "b.rs"]);
let files = changed_files(dir.path(), None).unwrap();
let names = names(&files);
assert!(!names.contains(&"b.rs".to_string()), "{names:?}");
}
#[test]
fn renamed_file_reported_under_new_name() {
let dir = tempdir().unwrap();
init_repo(dir.path());
write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
commit(dir.path(), "init");
git(dir.path(), &["mv", "a.rs", "b.rs"]);
let files = changed_files(dir.path(), None).unwrap();
let names = names(&files);
assert!(names.contains(&"b.rs".to_string()), "{names:?}");
assert!(!names.contains(&"a.rs".to_string()), "{names:?}");
}
#[test]
fn base_ref_uses_merge_base() {
let dir = tempdir().unwrap();
init_repo(dir.path());
write(dir.path().join("base.rs"), "fn base() {}\n").unwrap();
commit(dir.path(), "base");
git(dir.path(), &["checkout", "-q", "-b", "feature"]);
write(dir.path().join("feature.rs"), "fn feature() {}\n").unwrap();
commit(dir.path(), "feature");
let files = changed_files(dir.path(), Some("main")).unwrap();
assert_eq!(names(&files), vec!["feature.rs".to_string()]);
}
#[test]
fn unknown_base_ref_is_an_error() {
let dir = tempdir().unwrap();
init_repo(dir.path());
write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
commit(dir.path(), "init");
match changed_files(dir.path(), Some("no-such-ref")) {
Err(GitError::BadRef { base, .. }) => assert_eq!(base, "no-such-ref"),
other => panic!("expected BadRef, got {other:?}"),
}
}
#[test]
fn repository_without_commit_reports_staged_files() {
let dir = tempdir().unwrap();
init_repo(dir.path());
write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
git(dir.path(), &["add", "a.rs"]);
let files = changed_files(dir.path(), None).unwrap();
assert_eq!(names(&files), vec!["a.rs".to_string()]);
}
#[test]
fn dirty_files_reports_modified_and_untracked_not_clean() {
let dir = tempdir().unwrap();
init_repo(dir.path());
write(dir.path().join("committed.rs"), "fn a() {}\n").unwrap();
commit(dir.path(), "init");
write(dir.path().join("committed.rs"), "fn a() { let x = 1; }\n").unwrap();
write(dir.path().join("new.rs"), "fn b() {}\n").unwrap();
let dirty = dirty_files(dir.path()).unwrap();
let names = names(&dirty);
assert!(names.contains(&"committed.rs".to_string()), "{names:?}");
assert!(names.contains(&"new.rs".to_string()), "{names:?}");
assert!(dirty.iter().all(|p| p.is_absolute()), "{dirty:?}");
}
#[test]
fn dirty_files_empty_on_clean_tree() {
let dir = tempdir().unwrap();
init_repo(dir.path());
write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
commit(dir.path(), "init");
assert!(dirty_files(dir.path()).unwrap().is_empty());
}
#[test]
fn paths_are_absolute_when_run_from_a_subdirectory() {
let dir = tempdir().unwrap();
init_repo(dir.path());
let src = dir.path().join("src");
create_dir_all(&src).unwrap();
write(src.join("lib.rs"), "fn a() {}\n").unwrap();
commit(dir.path(), "init");
write(src.join("lib.rs"), "fn a() { let x = 1; }\n").unwrap();
let files = changed_files(&src, None).unwrap();
assert_eq!(files.len(), 1, "{files:?}");
assert!(files[0].is_absolute(), "{files:?}");
assert!(files[0].is_file(), "{files:?}");
}
}