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,
}
#[derive(Clone)]
struct Location {
root: PathBuf,
gitdir: PathBuf,
}
type LocationCache = std::sync::Mutex<std::collections::HashMap<PathBuf, Option<Location>>>;
fn location_cache() -> &'static LocationCache {
static CACHE: std::sync::OnceLock<LocationCache> = std::sync::OnceLock::new();
CACHE.get_or_init(Default::default)
}
fn locate_cached(root: &Path) -> Option<Location> {
let mut cache = location_cache().lock().unwrap_or_else(|e| e.into_inner());
if let Some(hit) = cache.get(root) {
return hit.clone();
}
let found = locate(root);
cache.insert(root.to_path_buf(), found.clone());
found
}
fn locate(root: &Path) -> Option<Location> {
let mut d = root.to_path_buf();
loop {
let g = d.join(".git");
if g.is_dir() {
return Some(Location { 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(Location {
root: d,
gitdir: abs,
});
}
if !d.pop() {
return None;
}
}
}
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> {
locate_cached(root).map(|l| Git {
root: l.root,
gitdir: l.gitdir,
})
}
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()));
}
#[test]
fn a_cached_location_still_reads_the_head_a_later_commit_left() {
let root = std::env::temp_dir().join(format!(
"vivac-anchor-live-{}-{}",
std::process::id(),
crate::id::ulid()
));
let git_dir = root.join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let first = "a".repeat(40);
std::fs::write(git_dir.join("HEAD"), &first).unwrap();
let before = detect(&root).snapshot();
assert_eq!(before.id, first, "the first read did not see the commit");
let second = "b".repeat(40);
std::fs::write(git_dir.join("HEAD"), &second).unwrap();
let after = detect(&root).snapshot();
assert_eq!(
after.id, second,
"the cached walk froze the commit instead of just the location"
);
std::fs::remove_dir_all(&root).ok();
}
}