use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use notify::{Config, Event, PollWatcher, RecursiveMode, Watcher};
use super::events::MemChangedEvent;
const POLL_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Debug, thiserror::Error)]
pub enum FileWatcherError {
#[error("refs/heads directory not found under gitdir: {0}")]
RefsHeadsMissing(PathBuf),
#[error("notify error: {0}")]
Notify(#[from] notify::Error),
#[error("io error reading initial refs/heads state: {0}")]
Io(#[from] std::io::Error),
}
pub struct MemRepoWatcher {
_watcher: PollWatcher,
_thread: Option<thread::JoinHandle<()>>,
}
impl std::fmt::Debug for MemRepoWatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MemRepoWatcher").finish()
}
}
pub fn watch_mem_repo(
gitdir: &Path,
) -> Result<(MemRepoWatcher, Receiver<MemChangedEvent>), FileWatcherError> {
let refs_heads = gitdir.join("refs").join("heads");
if !refs_heads.is_dir() {
return Err(FileWatcherError::RefsHeadsMissing(refs_heads));
}
let state: Arc<Mutex<HashMap<String, String>>> =
Arc::new(Mutex::new(scan_initial_state(&refs_heads)?));
let (event_tx, event_rx) = channel::<MemChangedEvent>();
let (notify_tx, notify_rx) = channel::<notify::Result<Event>>();
let mut watcher = PollWatcher::new(
move |res: notify::Result<Event>| {
let _ = notify_tx.send(res);
},
Config::default()
.with_poll_interval(POLL_INTERVAL)
.with_compare_contents(true),
)?;
watcher.watch(&refs_heads, RecursiveMode::Recursive)?;
let refs_heads_for_thread = refs_heads.clone();
let state_for_thread = state.clone();
let event_tx_for_thread = event_tx;
let join = thread::Builder::new()
.name("memstead-mem-repo-watcher".to_string())
.spawn(move || {
run_event_loop(
&refs_heads_for_thread,
state_for_thread,
notify_rx,
event_tx_for_thread,
);
})
.expect("spawning file-watcher thread must succeed");
Ok((
MemRepoWatcher {
_watcher: watcher,
_thread: Some(join),
},
event_rx,
))
}
fn scan_initial_state(refs_heads: &Path) -> Result<HashMap<String, String>, std::io::Error> {
let mut out = HashMap::new();
scan_dir(refs_heads, refs_heads, &mut out)?;
Ok(out)
}
fn scan_dir(
base: &Path,
dir: &Path,
out: &mut HashMap<String, String>,
) -> Result<(), std::io::Error> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
scan_dir(base, &path, out)?;
} else if file_type.is_file()
&& let Some(name) = mem_name_for_ref_path(base, &path)
&& let Some(sha) = read_ref_sha(&path)
{
out.insert(name, sha);
}
}
Ok(())
}
fn mem_name_for_ref_path(base: &Path, ref_path: &Path) -> Option<String> {
let rel = ref_path.strip_prefix(base).ok()?;
Some(
rel.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/"),
)
}
fn read_ref_sha(ref_path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(ref_path).ok()?;
let trimmed = raw.trim();
if trimmed.len() != 40 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
Some(trimmed.to_string())
}
fn run_event_loop(
refs_heads: &Path,
state: Arc<Mutex<HashMap<String, String>>>,
notify_rx: Receiver<notify::Result<Event>>,
event_tx: Sender<MemChangedEvent>,
) {
while let Ok(item) = notify_rx.recv() {
let Ok(event) = item else { continue };
for path in event.paths {
if !path.is_file() {
continue;
}
let Some(mem) = mem_name_for_ref_path(refs_heads, &path) else {
continue;
};
let Some(new_sha) = read_ref_sha(&path) else {
continue;
};
let previous = {
let mut map = state.lock().unwrap();
let prev = map.get(&mem).cloned().unwrap_or_default();
if prev == new_sha {
continue;
}
map.insert(mem.clone(), new_sha.clone());
prev
};
let emission = MemChangedEvent {
mem,
head: new_sha,
previous,
n_commits: 1,
};
if event_tx.send(emission).is_err() {
return;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn recv_event_for(
rx: &Receiver<MemChangedEvent>,
expected_mem: &str,
timeout: Duration,
) -> Option<MemChangedEvent> {
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.checked_duration_since(Instant::now())?;
match rx.recv_timeout(remaining) {
Ok(ev) if ev.mem == expected_mem => return Some(ev),
Ok(_) => continue,
Err(_) => return None,
}
}
}
fn make_refs_heads(tmp: &TempDir) -> PathBuf {
let gitdir = tmp.path().join("mem-repo.git");
std::fs::create_dir_all(gitdir.join("refs").join("heads")).unwrap();
gitdir
}
fn write_ref(refs_heads: &Path, mem: &str, sha: &str) {
let p = refs_heads.join(mem);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, format!("{sha}\n")).unwrap();
}
#[test]
fn refs_heads_missing_returns_typed_error() {
let tmp = TempDir::new().unwrap();
let err = watch_mem_repo(&tmp.path().join("nope")).unwrap_err();
match err {
FileWatcherError::RefsHeadsMissing(_) => {}
other => panic!("expected RefsHeadsMissing, got {other:?}"),
}
}
#[test]
fn modifying_ref_file_emits_mem_changed_event() {
let tmp = TempDir::new().unwrap();
let gitdir = make_refs_heads(&tmp);
let refs_heads = gitdir.join("refs").join("heads");
let initial = "1234567890abcdef1234567890abcdef12345678";
write_ref(&refs_heads, "specs", initial);
let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
let updated = "fedcba9876543210fedcba9876543210fedcba98";
write_ref(&refs_heads, "specs", updated);
let event = recv_event_for(&rx, "specs", Duration::from_secs(2))
.expect("event must arrive within 2s");
assert_eq!(event.mem, "specs");
assert_eq!(event.head, updated);
assert_eq!(event.previous, initial);
assert_eq!(event.n_commits, 1);
}
#[test]
fn creating_new_ref_file_emits_event_with_empty_previous() {
let tmp = TempDir::new().unwrap();
let gitdir = make_refs_heads(&tmp);
let refs_heads = gitdir.join("refs").join("heads");
let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
write_ref(&refs_heads, "newmem", sha);
let event = recv_event_for(&rx, "newmem", Duration::from_secs(2))
.expect("event must arrive within 2s");
assert_eq!(event.head, sha);
assert_eq!(event.previous, "");
}
#[test]
fn idempotent_writes_do_not_emit_duplicate_events() {
let tmp = TempDir::new().unwrap();
let gitdir = make_refs_heads(&tmp);
let refs_heads = gitdir.join("refs").join("heads");
write_ref(
&refs_heads,
"specs",
"1111111111111111111111111111111111111111",
);
let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
write_ref(
&refs_heads,
"specs",
"1111111111111111111111111111111111111111",
);
assert!(
recv_event_for(&rx, "specs", Duration::from_millis(200)).is_none(),
"idempotent re-write must not surface as a change event",
);
}
#[test]
fn hierarchical_branch_paths_produce_compound_mem_names() {
let tmp = TempDir::new().unwrap();
let gitdir = make_refs_heads(&tmp);
let refs_heads = gitdir.join("refs").join("heads");
let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
let sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
write_ref(&refs_heads, "team/specs", sha);
let event = recv_event_for(&rx, "team/specs", Duration::from_secs(2))
.expect("hierarchical event must arrive");
assert_eq!(event.mem, "team/specs");
assert_eq!(event.head, sha);
}
#[test]
fn dropping_watcher_stops_event_delivery() {
let tmp = TempDir::new().unwrap();
let gitdir = make_refs_heads(&tmp);
let refs_heads = gitdir.join("refs").join("heads");
let (watcher, rx) = watch_mem_repo(&gitdir).unwrap();
drop(watcher);
write_ref(
&refs_heads,
"specs",
"cccccccccccccccccccccccccccccccccccccccc",
);
assert!(
recv_event_for(&rx, "specs", Duration::from_millis(200)).is_none(),
"dropped watcher must not deliver further events",
);
}
}