use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Deserialize)]
pub struct Hook {
#[serde(default, rename = "when-changed")]
pub when_changed: Vec<String>,
pub run: String,
#[serde(default)]
pub cwd: Option<String>,
}
pub fn fingerprint(root: &Path, paths: &[String]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut entries: Vec<(String, u64)> = Vec::new();
for rel in paths {
collect(&root.join(rel), root, &mut entries, 0);
}
entries.sort();
let mut h = DefaultHasher::new();
entries.hash(&mut h);
format!("{:016x}", h.finish())
}
fn collect(path: &Path, root: &Path, out: &mut Vec<(String, u64)>, depth: usize) {
if depth > 8 {
return;
}
let Ok(meta) = std::fs::symlink_metadata(path) else {
return;
};
if meta.is_dir() {
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
for e in entries.filter_map(|e| e.ok()) {
collect(&e.path(), root, out, depth + 1);
}
return;
}
let key = path
.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string();
let stamp = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
out.push((key, stamp ^ (meta.len() << 20)));
}
pub fn run_all(
root: &Path,
hooks: &BTreeMap<String, Hook>,
previous: &BTreeMap<String, String>,
dry_run: bool,
) -> Result<BTreeMap<String, String>> {
let mut next = previous.clone();
for (name, hook) in hooks {
let fp = fingerprint(root, &hook.when_changed);
let changed = hook.when_changed.is_empty() || previous.get(name) != Some(&fp);
if !changed {
continue;
}
println!(" {:>8} {name}", "hook");
if dry_run {
continue;
}
let dir = match &hook.cwd {
Some(c) => root.join(c),
None => root.to_path_buf(),
};
let status = Command::new("sh")
.arg("-c")
.arg(&hook.run)
.current_dir(&dir)
.status()
.with_context(|| format!("hook `{name}`: failed to run"))?;
if !status.success() {
bail!("hook `{name}` failed: {}", hook.run);
}
next.insert(name.clone(), fp);
}
Ok(next)
}
#[cfg(test)]
mod tests {
use super::*;
fn hook(when: &[&str]) -> Hook {
Hook {
when_changed: when.iter().map(|s| s.to_string()).collect(),
run: "true".into(),
cwd: None,
}
}
#[test]
fn a_hook_without_watches_always_runs() {
let dir = std::env::temp_dir();
let mut hooks = BTreeMap::new();
hooks.insert("always".to_string(), hook(&[]));
let seen = BTreeMap::new();
let next = run_all(&dir, &hooks, &seen, true).unwrap();
assert!(next.is_empty());
}
#[test]
fn fingerprint_is_stable_for_unchanged_content() {
let dir = std::env::temp_dir().join("sennit-fp-test");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a"), "x").unwrap();
let a = fingerprint(&dir, &["a".into()]);
let b = fingerprint(&dir, &["a".into()]);
assert_eq!(a, b);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_paths_do_not_panic() {
let fp = fingerprint(Path::new("/nonexistent"), &["nope".into()]);
assert!(!fp.is_empty());
}
}