use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
pub type TreeFingerprint = BTreeMap<PathBuf, String>;
pub fn fingerprint(repo: &Path) -> Option<TreeFingerprint> {
let paths = changed_paths(repo)?;
let mut out = TreeFingerprint::new();
for rel in paths {
let hash = hash_object(repo, &rel).unwrap_or_else(|| "<absent>".to_string());
out.insert(rel, hash);
}
Some(out)
}
fn changed_paths(repo: &Path) -> Option<Vec<PathBuf>> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args([
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--no-renames",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(parse_porcelain_z(&String::from_utf8_lossy(&out.stdout)))
}
fn parse_porcelain_z(stdout: &str) -> Vec<PathBuf> {
stdout
.split('\0')
.filter(|rec| rec.len() > 3)
.filter_map(|rec| rec.get(3..))
.map(PathBuf::from)
.collect()
}
fn hash_object(repo: &Path, rel: &Path) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["hash-object", "--"])
.arg(rel)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
}
pub fn changed_between(green: &TreeFingerprint, now: &TreeFingerprint) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
for (path, hash) in now {
if green.get(path) != Some(hash) {
out.push(path.clone());
}
}
for path in green.keys() {
if !now.contains_key(path) {
out.push(path.clone());
}
}
out.sort();
out.dedup();
out
}
pub fn coverage_is_complete(mutated: &[PathBuf], restorable: &[PathBuf]) -> bool {
mutated
.iter()
.all(|m| restorable.iter().any(|r| r.ends_with(m)))
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(pairs: &[(&str, &str)]) -> TreeFingerprint {
pairs
.iter()
.map(|(p, h)| (PathBuf::from(p), h.to_string()))
.collect()
}
#[test]
fn porcelain_z_parses_status_codes_and_keeps_spaces() {
let raw = " M src/a.rs\0?? new file.rs\0A src/b.rs\0";
assert_eq!(
parse_porcelain_z(raw),
vec![
PathBuf::from("src/a.rs"),
PathBuf::from("new file.rs"),
PathBuf::from("src/b.rs"),
]
);
}
#[test]
fn porcelain_z_handles_awkward_paths() {
let raw = " M src/we\"ird.rs\0?? a\nb.rs\0";
assert_eq!(
parse_porcelain_z(raw),
vec![PathBuf::from("src/we\"ird.rs"), PathBuf::from("a\nb.rs")]
);
}
#[test]
fn porcelain_z_ignores_empty_and_short_records() {
assert!(parse_porcelain_z("").is_empty());
assert!(parse_porcelain_z("\0\0").is_empty());
assert!(parse_porcelain_z(" M\0").is_empty());
}
#[test]
fn changed_between_detects_modify_add_and_remove() {
let green = fp(&[("src/a.rs", "h1"), ("src/b.rs", "h2")]);
let now = fp(&[
("src/a.rs", "h1"),
("src/b.rs", "CHANGED"),
("src/c.rs", "h3"),
]);
assert_eq!(
changed_between(&green, &now),
vec![PathBuf::from("src/b.rs"), PathBuf::from("src/c.rs")]
);
let now2 = fp(&[("src/a.rs", "h1")]);
assert_eq!(
changed_between(&green, &now2),
vec![PathBuf::from("src/b.rs")]
);
}
#[test]
fn changed_between_is_empty_when_nothing_moved() {
let green = fp(&[("src/a.rs", "h1")]);
assert!(changed_between(&green, &green.clone()).is_empty());
assert!(changed_between(&green, &fp(&[("src/a.rs", "h1")])).is_empty());
}
#[test]
fn bash_mutated_file_breaks_coverage() {
let mutated = vec![PathBuf::from("src/a.rs"), PathBuf::from("src/sedded.rs")];
let restorable = vec![PathBuf::from("/repo/src/a.rs")];
assert!(
!coverage_is_complete(&mutated, &restorable),
"a file the store never captured must block auto"
);
}
#[test]
fn coverage_complete_when_store_has_every_mutated_path() {
let mutated = vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")];
let restorable = vec![
PathBuf::from("/repo/src/a.rs"),
PathBuf::from("/repo/src/b.rs"),
PathBuf::from("/repo/src/extra.rs"),
];
assert!(
coverage_is_complete(&mutated, &restorable),
"a store superset is still complete coverage"
);
}
#[test]
fn nothing_mutated_is_trivially_covered() {
assert!(coverage_is_complete(&[], &[]));
}
#[test]
fn mutations_with_empty_store_are_not_covered() {
assert!(!coverage_is_complete(&[PathBuf::from("src/a.rs")], &[]));
}
fn git(dir: &Path, args: &[&str]) -> bool {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn temp_repo() -> Option<PathBuf> {
let dir = std::env::temp_dir().join(format!(
"dirge-wtp-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).ok()?;
if !git(&dir, &["init", "-q"]) {
return None; }
let _ = git(&dir, &["config", "user.email", "t@t"]);
let _ = git(&dir, &["config", "user.name", "t"]);
std::fs::write(dir.join("src/a.rs"), "fn a() {}\n").ok()?;
std::fs::write(dir.join("src/b.rs"), "fn b() {}\n").ok()?;
git(&dir, &["add", "-A"]).then_some(())?;
git(&dir, &["commit", "-qm", "base"]).then_some(())?;
Some(dir)
}
#[test]
fn detects_a_mutation_the_snapshot_store_would_miss() {
let Some(repo) = temp_repo() else { return };
let green = fingerprint(&repo).expect("git available");
assert!(green.is_empty(), "clean tree fingerprints empty: {green:?}");
std::fs::write(repo.join("src/a.rs"), "fn a() { /* sed */ }\n").unwrap();
let now = fingerprint(&repo).expect("git available");
let mutated = changed_between(&green, &now);
assert_eq!(mutated, vec![PathBuf::from("src/a.rs")]);
assert!(!coverage_is_complete(&mutated, &[]));
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn pre_existing_dirty_work_is_not_a_restore_target() {
let Some(repo) = temp_repo() else { return };
std::fs::write(repo.join("src/b.rs"), "fn b() { /* user's wip */ }\n").unwrap();
let green = fingerprint(&repo).expect("git available");
assert_eq!(green.len(), 1, "b.rs differs from HEAD at green");
std::fs::write(repo.join("src/a.rs"), "fn a() { changed }\n").unwrap();
let now = fingerprint(&repo).expect("git available");
let mutated = changed_between(&green, &now);
assert_eq!(
mutated,
vec![PathBuf::from("src/a.rs")],
"the user's pre-run WIP must not be listed"
);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn detects_untracked_file_created_after_green() {
let Some(repo) = temp_repo() else { return };
let green = fingerprint(&repo).expect("git available");
std::fs::write(repo.join("src/new.rs"), "fn n() {}\n").unwrap();
let now = fingerprint(&repo).expect("git available");
assert_eq!(
changed_between(&green, &now),
vec![PathBuf::from("src/new.rs")]
);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn non_repo_yields_none_so_auto_declines() {
let dir = std::env::temp_dir().join(format!("dirge-wtp-norepo-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(fingerprint(&dir).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}