use std::{
collections::hash_map::DefaultHasher,
fs::Metadata,
hash::{Hash, Hasher},
path::{Path, PathBuf},
process::{Command, Stdio},
time::UNIX_EPOCH,
};
const FINGERPRINT_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fingerprint {
digest: u64,
files: usize,
}
impl Fingerprint {
pub const fn files(self) -> usize {
self.files
}
pub fn hex(self) -> String {
format!("{:016x}", self.digest)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Snapshot {
Known(Fingerprint),
Unknown { reason: String },
}
impl Snapshot {
pub const fn fingerprint(&self) -> Option<Fingerprint> {
match self {
Self::Known(fingerprint) => Some(*fingerprint),
Self::Unknown { .. } => None,
}
}
}
pub fn snapshot(workspace: &Path) -> Snapshot {
if !workspace.is_dir() {
return Snapshot::Unknown {
reason: format!("{} is not a directory", workspace.display()),
};
}
let listing = git_listing(workspace).unwrap_or_else(|| walk_listing(workspace));
fingerprint(workspace, listing)
}
#[derive(Debug)]
enum Listing {
Git { head: String, paths: Vec<PathBuf> },
Walk { paths: Vec<PathBuf> },
}
impl Listing {
const fn tag(&self) -> u8 {
match self {
Self::Git { .. } => 1,
Self::Walk { .. } => 2,
}
}
fn into_parts(self) -> (u8, String, Vec<PathBuf>) {
let tag = self.tag();
match self {
Self::Git { head, paths } => (tag, head, paths),
Self::Walk { paths } => (tag, String::new(), paths),
}
}
}
fn fingerprint(workspace: &Path, listing: Listing) -> Snapshot {
let (tag, head, mut paths) = listing.into_parts();
paths.sort_unstable();
paths.dedup();
if paths.is_empty() {
return Snapshot::Unknown {
reason: format!("no files found under {}", workspace.display()),
};
}
let mut hasher = DefaultHasher::new();
FINGERPRINT_VERSION.hash(&mut hasher);
tag.hash(&mut hasher);
head.hash(&mut hasher);
for path in &paths {
path.hash(&mut hasher);
match std::fs::symlink_metadata(workspace.join(path)) {
Ok(metadata) => {
1u8.hash(&mut hasher);
metadata.len().hash(&mut hasher);
modified_nanos(&metadata).hash(&mut hasher);
}
Err(_) => 0u8.hash(&mut hasher),
}
}
Snapshot::Known(Fingerprint {
digest: hasher.finish(),
files: paths.len(),
})
}
fn modified_nanos(metadata: &Metadata) -> Option<i128> {
let modified = metadata.modified().ok()?;
Some(match modified.duration_since(UNIX_EPOCH) {
Ok(since) => since.as_nanos() as i128,
Err(before) => -(before.duration().as_nanos() as i128),
})
}
fn git_listing(workspace: &Path) -> Option<Listing> {
if git(workspace, &["rev-parse", "--is-inside-work-tree"])?.trim() != "true" {
return None;
}
let head = git(workspace, &["rev-parse", "HEAD"])
.unwrap_or_default()
.trim()
.to_string();
let listed = git_bytes(
workspace,
&[
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
],
)?;
let paths = listed
.split(|byte| *byte == 0)
.filter(|entry| !entry.is_empty())
.map(path_from_bytes)
.collect();
Some(Listing::Git { head, paths })
}
fn git(workspace: &Path, args: &[&str]) -> Option<String> {
let bytes = git_bytes(workspace, args)?;
String::from_utf8(bytes).ok()
}
fn git_bytes(workspace: &Path, args: &[&str]) -> Option<Vec<u8>> {
let output = Command::new("git")
.arg("-C")
.arg(workspace)
.args(args)
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
output.status.success().then_some(output.stdout)
}
fn walk_listing(workspace: &Path) -> Listing {
let mut paths = Vec::new();
let mut pending = vec![workspace.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
if let Some(relative) = relative(workspace, &directory) {
paths.push(relative);
}
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.file_name().is_some_and(|name| name == ".git") {
continue;
}
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if metadata.is_dir() {
pending.push(path);
} else if let Some(relative) = relative(workspace, &path) {
paths.push(relative);
}
}
}
Listing::Walk { paths }
}
fn relative(workspace: &Path, path: &Path) -> Option<PathBuf> {
path.strip_prefix(workspace).ok().map(Path::to_path_buf)
}
#[cfg(unix)]
fn path_from_bytes(bytes: &[u8]) -> PathBuf {
use std::os::unix::ffi::OsStrExt;
PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
}
#[cfg(not(unix))]
fn path_from_bytes(bytes: &[u8]) -> PathBuf {
PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn write_at(path: &Path, body: &str, epoch_seconds: u64) {
std::fs::write(path, body).expect("write");
touch(path, epoch_seconds);
}
fn touch(path: &Path, epoch_seconds: u64) {
std::fs::File::options()
.write(true)
.open(path)
.expect("open")
.set_modified(UNIX_EPOCH + std::time::Duration::from_secs(epoch_seconds))
.expect("set mtime");
}
fn write(path: &Path, body: &str) {
write_at(path, body, 1_700_000_000);
}
fn known(workspace: &Path) -> Fingerprint {
match snapshot(workspace) {
Snapshot::Known(fingerprint) => fingerprint,
Snapshot::Unknown { reason } => panic!("expected a fingerprint, got: {reason}"),
}
}
#[test]
fn a_missing_workspace_is_unknown_rather_than_empty() {
assert!(matches!(
snapshot(Path::new("/definitely/not/a/real/path")),
Snapshot::Unknown { .. }
));
}
#[test]
fn an_empty_workspace_is_unknown_rather_than_unchanged() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(matches!(snapshot(dir.path()), Snapshot::Unknown { .. }));
}
#[test]
fn the_same_tree_fingerprints_the_same_twice() {
let dir = tempfile::tempdir().expect("tempdir");
write(&dir.path().join("a.txt"), "one");
assert_eq!(known(dir.path()), known(dir.path()));
}
#[test]
fn an_edit_changes_the_fingerprint() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("a.txt");
write(&file, "one");
let before = known(dir.path());
write_at(&file, "two", 1_700_000_060);
assert_ne!(known(dir.path()), before);
}
#[test]
fn a_same_length_edit_changes_the_fingerprint() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("a.txt");
write(&file, "aaa");
let before = known(dir.path());
write_at(&file, "bbb", 1_700_000_060);
assert_ne!(known(dir.path()), before);
}
#[test]
fn a_same_mtime_edit_of_a_different_length_changes_the_fingerprint() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("a.txt");
write(&file, "aaa");
let before = known(dir.path());
write_at(&file, "aaaa", 1_700_000_000);
assert_ne!(known(dir.path()), before);
}
#[test]
fn an_mtime_moving_backwards_still_reads_as_changed() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("a.txt");
write(&file, "one");
let before = known(dir.path());
touch(&file, 1);
assert_ne!(known(dir.path()), before);
}
#[test]
fn a_new_file_changes_the_fingerprint() {
let dir = tempfile::tempdir().expect("tempdir");
write(&dir.path().join("a.txt"), "one");
let before = known(dir.path());
write(&dir.path().join("b.txt"), "two");
assert_ne!(known(dir.path()), before);
}
#[test]
fn a_deleted_file_changes_the_fingerprint() {
let dir = tempfile::tempdir().expect("tempdir");
write(&dir.path().join("a.txt"), "one");
write(&dir.path().join("b.txt"), "two");
let before = known(dir.path());
std::fs::remove_file(dir.path().join("b.txt")).expect("remove");
assert_ne!(known(dir.path()), before);
}
#[test]
fn a_new_subdirectory_of_files_changes_the_fingerprint() {
let dir = tempfile::tempdir().expect("tempdir");
write(&dir.path().join("a.txt"), "one");
let before = known(dir.path());
std::fs::create_dir(dir.path().join("nested")).expect("mkdir");
write(&dir.path().join("nested/b.txt"), "two");
assert_ne!(known(dir.path()), before);
}
#[test]
fn the_walk_does_not_follow_symlinks() {
let dir = tempfile::tempdir().expect("tempdir");
write(&dir.path().join("a.txt"), "one");
#[cfg(unix)]
std::os::unix::fs::symlink(dir.path(), dir.path().join("loop")).expect("symlink");
let _ = known(dir.path());
}
#[test]
fn the_digest_is_reported_as_stable_hex() {
let dir = tempfile::tempdir().expect("tempdir");
write(&dir.path().join("a.txt"), "one");
let fingerprint = known(dir.path());
assert_eq!(fingerprint.hex().len(), 16);
assert_eq!(fingerprint.hex(), known(dir.path()).hex());
assert_eq!(fingerprint.files(), 1);
}
#[test]
fn an_unknown_snapshot_keeps_no_baseline() {
let unknown = Snapshot::Unknown {
reason: "gone".to_string(),
};
assert_eq!(unknown.fingerprint(), None);
}
}