use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ChangeKind {
Added,
Modified,
Deleted,
Renamed { old_path: PathBuf },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChangedFile {
pub path: PathBuf,
pub kind: ChangeKind,
}
pub fn changed_files(root: &Path, base: &str) -> Result<Vec<ChangedFile>> {
let output = git(root, &["diff", "--name-status", "--find-renames", base])?;
let mut changes = Vec::new();
let mut known = BTreeSet::new();
for line in output.lines() {
let fields: Vec<_> = line.split('\t').collect();
let Some(status) = fields.first() else {
continue;
};
let change = match status.chars().next() {
Some('A') if fields.len() >= 2 => ChangedFile {
path: root.join(fields[1]),
kind: ChangeKind::Added,
},
Some('M' | 'T') if fields.len() >= 2 => ChangedFile {
path: root.join(fields[1]),
kind: ChangeKind::Modified,
},
Some('D') if fields.len() >= 2 => ChangedFile {
path: root.join(fields[1]),
kind: ChangeKind::Deleted,
},
Some('R') if fields.len() >= 3 => ChangedFile {
path: root.join(fields[2]),
kind: ChangeKind::Renamed {
old_path: PathBuf::from(fields[1]),
},
},
_ => continue,
};
known.insert(change.path.clone());
changes.push(change);
}
let untracked = git(root, &["ls-files", "--others", "--exclude-standard"])?;
for path in untracked.lines().filter(|path| !path.is_empty()) {
let path = root.join(path);
if known.insert(path.clone()) {
changes.push(ChangedFile {
path,
kind: ChangeKind::Added,
});
}
}
changes.sort_by(|left, right| left.path.cmp(&right.path));
Ok(changes)
}
pub fn file_at(root: &Path, revision: &str, path: &Path) -> Result<Option<String>> {
let relative = path.strip_prefix(root).unwrap_or(path);
let object = format!("{revision}:{}", relative.to_string_lossy());
let output = Command::new("git")
.args(["show", &object])
.current_dir(root)
.output()
.with_context(|| format!("failed to run git show for {}", relative.display()))?;
if output.status.success() {
return String::from_utf8(output.stdout)
.context("git returned non-UTF-8 source")
.map(Some);
}
Ok(None)
}
pub fn repository_root(start: &Path) -> Result<PathBuf> {
let output = git(start, &["rev-parse", "--show-toplevel"])?;
Ok(PathBuf::from(output.trim()))
}
fn git(root: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
if !output.status.success() {
bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
String::from_utf8(output.stdout).context("git returned non-UTF-8 output")
}