Skip to main content

gitkraft_core/features/repo/
watcher.rs

1//! Background git-state watcher.
2//!
3//! Watches the `.git` directory with the OS's native file-system notification
4//! API (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows)
5//! and calls a callback after each debounced burst of changes.
6//!
7//! Reactive events fire within ~300 ms of any `.git` change.
8//! A configurable fallback poll fires when no events arrive within the timeout
9//! (default 60 s) — useful for network file systems or CI environments where
10//! inotify events may not be delivered.
11
12use std::path::PathBuf;
13use std::sync::mpsc;
14use std::thread::{self, JoinHandle};
15use std::time::Duration;
16
17/// Spawn a background thread that watches `git_dir` for changes and calls
18/// `on_change` after each debounced burst.
19///
20/// Uses a 60-second fallback poll so that the UI eventually reflects external
21/// changes even on network file systems. Use [`spawn_git_watcher_with_fallback`]
22/// when a custom fallback interval is needed (e.g. in tests).
23pub fn spawn_git_watcher<F>(git_dir: PathBuf, on_change: F) -> JoinHandle<()>
24where
25    F: Fn() -> bool + Send + 'static,
26{
27    spawn_git_watcher_with_fallback(git_dir, Duration::from_secs(60), on_change)
28}
29
30/// Like [`spawn_git_watcher`] but with a custom fallback poll `timeout`.
31///
32/// Useful in tests to keep waiting times short.
33pub(crate) fn spawn_git_watcher_with_fallback<F>(
34    git_dir: PathBuf,
35    fallback: Duration,
36    on_change: F,
37) -> JoinHandle<()>
38where
39    F: Fn() -> bool + Send + 'static,
40{
41    thread::spawn(move || {
42        use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
43
44        let (raw_tx, raw_rx) = mpsc::channel::<notify::Result<notify::Event>>();
45
46        let mut watcher = RecommendedWatcher::new(
47            move |res| {
48                let _ = raw_tx.send(res);
49            },
50            Config::default(),
51        )
52        .ok();
53
54        if let Some(ref mut w) = watcher {
55            // Watch top-level .git files NON-recursively: HEAD, index,
56            // packed-refs, MERGE_HEAD, COMMIT_EDITMSG, etc.
57            // Intentionally excludes objects/, pack/, and logs/ which git2
58            // writes to when READING commits — watching those directories
59            // causes a read→write→notify→refresh→read loop.
60            let _ = w.watch(&git_dir, RecursiveMode::NonRecursive);
61
62            // Watch refs/ recursively to catch branch/tag/stash changes
63            // (.git/refs/heads/, .git/refs/tags/, .git/refs/stash, …).
64            let refs_dir = git_dir.join("refs");
65            if refs_dir.exists() {
66                let _ = w.watch(&refs_dir, RecursiveMode::Recursive);
67            }
68        }
69
70        loop {
71            // Block until a notify event arrives or the fallback timeout elapses.
72            let _ = raw_rx.recv_timeout(fallback);
73            // Drain any extra events so a rapid burst counts as one refresh.
74            while raw_rx.try_recv().is_ok() {}
75            // Debounce: give git time to finish writing all its index files.
76            thread::sleep(Duration::from_millis(300));
77            while raw_rx.try_recv().is_ok() {}
78
79            // Call the callback; stop the thread if it returns false.
80            if !on_change() {
81                break;
82            }
83        }
84    })
85}
86
87// ── Tests ────────────────────────────────────────────────────────────────────
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use std::sync::{Arc, Mutex};
93
94    fn wait_for<F: Fn() -> bool>(condition: F, timeout: Duration) -> bool {
95        let deadline = std::time::Instant::now() + timeout;
96        while std::time::Instant::now() < deadline {
97            if condition() {
98                return true;
99            }
100            thread::sleep(Duration::from_millis(50));
101        }
102        false
103    }
104
105    #[test]
106    fn watcher_calls_callback_when_git_head_changes() {
107        let dir = tempfile::tempdir().unwrap();
108        let git_dir = dir.path().join(".git");
109        std::fs::create_dir_all(&git_dir).unwrap();
110        // Create a minimal HEAD file so the directory looks like a git repo.
111        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
112
113        let fired = Arc::new(Mutex::new(false));
114        let fired_clone = Arc::clone(&fired);
115
116        let _handle = spawn_git_watcher(git_dir.clone(), move || {
117            *fired_clone.lock().unwrap() = true;
118            false // stop after the first callback
119        });
120
121        // Give the watcher a moment to set up before triggering a change.
122        thread::sleep(Duration::from_millis(200));
123
124        // Simulate a branch checkout by rewriting HEAD.
125        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
126
127        // The callback fires after the 300 ms debounce — wait up to 2 s.
128        assert!(
129            wait_for(|| *fired.lock().unwrap(), Duration::from_secs(2)),
130            "watcher did not call on_change within 2 seconds after HEAD changed"
131        );
132    }
133
134    #[test]
135    fn watcher_fires_fallback_poll_when_no_events() {
136        // Use a path that doesn't exist — notify will fail to watch it, so no
137        // events will ever arrive.  Uses a 2-second fallback (not the production
138        // 60-second default) to keep the test fast.
139        let dir = tempfile::tempdir().unwrap();
140        let fake_git = dir.path().join(".git_nonexistent");
141
142        let fired = Arc::new(Mutex::new(false));
143        let fired_clone = Arc::clone(&fired);
144
145        let _handle =
146            spawn_git_watcher_with_fallback(fake_git, Duration::from_secs(2), move || {
147                *fired_clone.lock().unwrap() = true;
148                false
149            });
150
151        // 2 s fallback + 300 ms debounce + margin.
152        assert!(
153            wait_for(|| *fired.lock().unwrap(), Duration::from_secs(4)),
154            "fallback poll did not fire within 4 seconds"
155        );
156    }
157
158    #[test]
159    fn watcher_thread_exits_when_callback_returns_false() {
160        let dir = tempfile::tempdir().unwrap();
161        let git_dir = dir.path().join(".git");
162        std::fs::create_dir_all(&git_dir).unwrap();
163
164        // 2-second fallback so the thread exits quickly in CI.
165        let handle =
166            spawn_git_watcher_with_fallback(git_dir.clone(), Duration::from_secs(2), move || {
167                false // immediately request exit on first call
168            });
169
170        // Thread must finish within 4 s (2 s fallback + 300 ms debounce + margin).
171        assert!(
172            wait_for(|| handle.is_finished(), Duration::from_secs(4)),
173            "watcher thread did not exit after callback returned false"
174        );
175    }
176}