use std::path::Path;
use std::time::SystemTime;
pub(crate) const POLLED_GITDIR_ENTRIES: [&str; 4] = ["HEAD", "index", "packed-refs", "refs"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct GitdirFingerprint([Option<SystemTime>; 4]);
pub(crate) fn fingerprint(gitdir: &Path) -> GitdirFingerprint {
let mut mtimes: [Option<SystemTime>; 4] = Default::default();
for (slot, name) in mtimes.iter_mut().zip(POLLED_GITDIR_ENTRIES) {
*slot = std::fs::metadata(gitdir.join(name))
.and_then(|metadata| metadata.modified())
.ok();
}
GitdirFingerprint(mtimes)
}
pub(crate) fn moved(previous: &GitdirFingerprint, current: &GitdirFingerprint) -> bool {
previous != current
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::Duration;
#[test]
fn polled_gitdir_entries_are_pinned_to_the_spec() {
let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/refresh.md");
let spec = fs::read_to_string(&spec_path)
.unwrap_or_else(|error| panic!("read {}: {error}", spec_path.display()));
let line = spec
.lines()
.find(|line| line.contains("stat-ing"))
.unwrap_or_else(|| {
panic!("no line containing \"stat-ing\" in {}", spec_path.display())
});
let after_stat_ing = line
.split_once("stat-ing")
.map(|(_, rest)| rest)
.unwrap_or(line);
let named: Vec<String> = after_stat_ing
.split('`')
.enumerate()
.filter(|(index, _)| index % 2 == 1)
.map(|(_, name)| name.trim_end_matches('/').to_string())
.collect();
assert_eq!(
named,
POLLED_GITDIR_ENTRIES.to_vec(),
"the spec's \"stat-ing\" line and POLLED_GITDIR_ENTRIES must name the same four \
paths in the same order"
);
}
fn touch(path: &Path) {
fs::write(path, b"x").expect("write a fixture file");
}
fn set_mtime(path: &Path, at: SystemTime) {
let file = fs::File::options()
.write(true)
.open(path)
.expect("open a fixture file to backdate");
file.set_modified(at).expect("backdate a fixture file");
}
#[test]
fn a_touched_named_path_registers_as_movement() {
let dir = tempfile::tempdir().expect("temp dir");
touch(&dir.path().join("HEAD"));
let before = fingerprint(dir.path());
set_mtime(
&dir.path().join("HEAD"),
SystemTime::now() + Duration::from_secs(5),
);
let after = fingerprint(dir.path());
assert!(
moved(&before, &after),
"a changed mtime on a literally-named path must register as movement"
);
}
#[test]
fn a_gitdir_with_none_of_the_four_paths_yet_reads_as_no_movement_against_itself() {
let dir = tempfile::tempdir().expect("temp dir");
let first = fingerprint(dir.path());
let second = fingerprint(dir.path());
assert!(
!moved(&first, &second),
"an absent path must read consistently, never flicker between readings"
);
}
#[test]
fn a_path_created_since_the_last_reading_registers_as_movement() {
let dir = tempfile::tempdir().expect("temp dir");
let before = fingerprint(dir.path());
touch(&dir.path().join("packed-refs"));
let after = fingerprint(dir.path());
assert!(
moved(&before, &after),
"a path that did not exist and now does must register as movement"
);
}
#[test]
fn a_touched_lock_file_never_registers_as_movement() {
let dir = tempfile::tempdir().expect("temp dir");
touch(&dir.path().join("HEAD"));
touch(&dir.path().join("HEAD.lock"));
let before = fingerprint(dir.path());
set_mtime(
&dir.path().join("HEAD.lock"),
SystemTime::now() + Duration::from_secs(5),
);
let after = fingerprint(dir.path());
assert!(
!moved(&before, &after),
"touching only a `.lock` file must never register as movement"
);
}
#[test]
fn a_name_that_only_contains_a_polled_name_never_registers_as_movement() {
let dir = tempfile::tempdir().expect("temp dir");
touch(&dir.path().join("HEAD"));
touch(&dir.path().join("HEADER"));
let before = fingerprint(dir.path());
set_mtime(
&dir.path().join("HEADER"),
SystemTime::now() + Duration::from_secs(5),
);
let after = fingerprint(dir.path());
assert!(
!moved(&before, &after),
"a decoy file whose name merely contains a polled name must never register as \
movement"
);
}
}