use std::path::PathBuf;
use std::sync::mpsc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
pub fn spawn_git_watcher<F>(git_dir: PathBuf, on_change: F) -> JoinHandle<()>
where
F: Fn() -> bool + Send + 'static,
{
spawn_git_watcher_with_fallback(git_dir, Duration::from_secs(60), on_change)
}
pub(crate) fn spawn_git_watcher_with_fallback<F>(
git_dir: PathBuf,
fallback: Duration,
on_change: F,
) -> JoinHandle<()>
where
F: Fn() -> bool + Send + 'static,
{
thread::spawn(move || {
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
let (raw_tx, raw_rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = RecommendedWatcher::new(
move |res| {
let _ = raw_tx.send(res);
},
Config::default(),
)
.ok();
if let Some(ref mut w) = watcher {
let _ = w.watch(&git_dir, RecursiveMode::NonRecursive);
let refs_dir = git_dir.join("refs");
if refs_dir.exists() {
let _ = w.watch(&refs_dir, RecursiveMode::Recursive);
}
}
loop {
let is_relevant = |res: ¬ify::Result<notify::Event>| match res {
Ok(ev) => {
!matches!(ev.kind, notify::EventKind::Access(_))
&& !ev
.paths
.iter()
.all(|p| p.extension().is_some_and(|e| e == "lock"))
}
Err(_) => true,
};
let deadline = std::time::Instant::now() + fallback;
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match raw_rx.recv_timeout(left) {
Ok(res) if !is_relevant(&res) => continue,
_ => break,
}
}
thread::sleep(Duration::from_millis(300));
while raw_rx.try_recv().is_ok() {}
if !on_change() {
break;
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
fn wait_for<F: Fn() -> bool>(condition: F, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if condition() {
return true;
}
thread::sleep(Duration::from_millis(50));
}
false
}
#[test]
fn watcher_calls_callback_when_git_head_changes() {
let dir = tempfile::tempdir().unwrap();
let git_dir = dir.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
let fired = Arc::new(Mutex::new(false));
let fired_clone = Arc::clone(&fired);
let _handle = spawn_git_watcher(git_dir.clone(), move || {
*fired_clone.lock().unwrap() = true;
false });
thread::sleep(Duration::from_millis(200));
std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
assert!(
wait_for(|| *fired.lock().unwrap(), Duration::from_secs(2)),
"watcher did not call on_change within 2 seconds after HEAD changed"
);
}
#[test]
fn watcher_fires_fallback_poll_when_no_events() {
let dir = tempfile::tempdir().unwrap();
let fake_git = dir.path().join(".git_nonexistent");
let fired = Arc::new(Mutex::new(false));
let fired_clone = Arc::clone(&fired);
let _handle =
spawn_git_watcher_with_fallback(fake_git, Duration::from_secs(2), move || {
*fired_clone.lock().unwrap() = true;
false
});
assert!(
wait_for(|| *fired.lock().unwrap(), Duration::from_secs(4)),
"fallback poll did not fire within 4 seconds"
);
}
#[test]
fn watcher_thread_exits_when_callback_returns_false() {
let dir = tempfile::tempdir().unwrap();
let git_dir = dir.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let handle =
spawn_git_watcher_with_fallback(git_dir.clone(), Duration::from_secs(2), move || {
false });
assert!(
wait_for(|| handle.is_finished(), Duration::from_secs(4)),
"watcher thread did not exit after callback returned false"
);
}
#[test]
fn watcher_ignores_read_only_access() {
let dir = tempfile::tempdir().unwrap();
let git_dir = dir.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
let fired = Arc::new(Mutex::new(false));
let fired_clone = Arc::clone(&fired);
let _handle =
spawn_git_watcher_with_fallback(git_dir.clone(), Duration::from_secs(5), move || {
*fired_clone.lock().unwrap() = true;
false
});
thread::sleep(Duration::from_millis(200));
for _ in 0..5 {
let _ = std::fs::read_to_string(git_dir.join("HEAD")).unwrap();
thread::sleep(Duration::from_millis(50));
}
assert!(
!wait_for(|| *fired.lock().unwrap(), Duration::from_secs(2)),
"read-only access wrongly triggered on_change"
);
}
#[test]
fn watcher_ignores_lock_file_only_changes() {
let dir = tempfile::tempdir().unwrap();
let git_dir = dir.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let fired = Arc::new(Mutex::new(false));
let fired_clone = Arc::clone(&fired);
let _handle =
spawn_git_watcher_with_fallback(git_dir.clone(), Duration::from_secs(5), move || {
*fired_clone.lock().unwrap() = true;
false
});
thread::sleep(Duration::from_millis(200));
std::fs::write(git_dir.join("index.lock"), "x").unwrap();
std::fs::remove_file(git_dir.join("index.lock")).unwrap();
assert!(
!wait_for(|| *fired.lock().unwrap(), Duration::from_secs(2)),
"lock-file change wrongly triggered on_change"
);
}
}