use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct AnchorRef {
pub kind: String,
pub id: String,
}
impl AnchorRef {
pub fn is_empty_tree(&self) -> bool {
self.id.is_empty()
}
pub fn short(&self) -> &str {
let n = self.id.len().min(7);
&self.id[..n]
}
}
#[derive(Debug, Clone)]
pub struct Change {
pub file_path: String,
pub times: usize,
}
pub trait Anchor {
fn snapshot(&self) -> AnchorRef;
fn changed_since(&self, r: &AnchorRef) -> Vec<Change>;
}
pub struct Null;
impl Anchor for Null {
fn snapshot(&self) -> AnchorRef {
AnchorRef {
kind: "null".into(),
id: String::new(),
}
}
fn changed_since(&self, _r: &AnchorRef) -> Vec<Change> {
vec![]
}
}
pub struct Git {
root: PathBuf,
gitdir: PathBuf,
}
pub fn detect(root: &Path) -> Box<dyn Anchor> {
match Git::new(root) {
Some(g) => Box::new(g),
None => Box::new(Null),
}
}
impl Git {
fn new(root: &Path) -> Option<Git> {
let mut d = root.to_path_buf();
loop {
let g = d.join(".git");
if g.is_dir() {
return Some(Git { root: d, gitdir: g });
}
if g.is_file() {
let t = std::fs::read_to_string(&g).ok()?;
let p = t.trim().strip_prefix("gitdir:")?.trim();
let abs = if Path::new(p).is_absolute() {
PathBuf::from(p)
} else {
d.join(p)
};
return Some(Git {
root: d,
gitdir: abs,
});
}
if !d.pop() {
return None;
}
}
}
fn head(&self) -> Option<String> {
let h = std::fs::read_to_string(self.gitdir.join("HEAD")).ok()?;
let h = h.trim();
let Some(refname) = h.strip_prefix("ref:").map(str::trim) else {
return is_sha(h).then(|| h.to_string());
};
if let Ok(s) = std::fs::read_to_string(self.gitdir.join(refname)) {
let s = s.trim().to_string();
if is_sha(&s) {
return Some(s);
}
}
let packed = std::fs::read_to_string(self.gitdir.join("packed-refs")).ok()?;
packed.lines().find_map(|l| {
let (sha, name) = l.split_once(' ')?;
(name.trim() == refname && is_sha(sha)).then(|| sha.to_string())
})
}
fn git(&self, args: &[&str]) -> Option<String> {
let s = std::process::Command::new("git")
.arg("-C")
.arg(&self.root)
.args(args)
.output()
.ok()?;
s.status
.success()
.then(|| String::from_utf8_lossy(&s.stdout).into_owned())
}
}
fn is_sha(s: &str) -> bool {
s.len() >= 7 && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
impl Anchor for Git {
fn snapshot(&self) -> AnchorRef {
AnchorRef {
kind: "git".into(),
id: self.head().unwrap_or_default(),
}
}
fn changed_since(&self, r: &AnchorRef) -> Vec<Change> {
if r.is_empty_tree() || r.kind != "git" {
return vec![];
}
let mut count: std::collections::BTreeMap<String, usize> = Default::default();
if let Some(out) = self.git(&[
"log",
"--format=",
"--name-only",
&format!("{}..HEAD", r.id),
]) {
for l in out.lines().map(str::trim).filter(|l| !l.is_empty()) {
*count.entry(l.to_string()).or_default() += 1;
}
}
if let Some(out) = self.git(&["status", "--porcelain", "-uall"]) {
for l in out.lines() {
if let Some(file_path) = l.get(3..) {
let file_path = file_path.rsplit(" -> ").next().unwrap_or(file_path).trim();
if !file_path.is_empty() {
*count
.entry(file_path.trim_matches('"').to_string())
.or_default() += 1;
}
}
}
}
let mut v: Vec<Change> = count
.into_iter()
.map(|(file_path, times)| Change { file_path, times })
.collect();
v.sort_by(|a, b| {
b.times
.cmp(&a.times)
.then_with(|| a.file_path.cmp(&b.file_path))
});
v
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn null_invents_no_precision() {
let n = Null;
assert!(n.snapshot().is_empty_tree());
assert!(n.changed_since(&AnchorRef::default()).is_empty());
}
#[test]
fn head_is_read_without_spawning_git() {
let g = Git::new(Path::new(".")).expect("vivac/ is a git repo");
let s = g.snapshot();
assert_eq!(s.kind, "git");
assert!(is_sha(&s.id), "HEAD did not resolve: {:?}", s.id);
assert_eq!(s.short().len(), 7);
}
#[test]
fn an_anchor_from_another_world_gives_no_changes() {
let g = Git::new(Path::new(".")).unwrap();
let bogus = AnchorRef {
kind: "git".into(),
id: "0000000000000000000000000000000000000000".into(),
};
assert!(g
.changed_since(&bogus)
.iter()
.all(|c| !c.file_path.is_empty()));
}
}