Skip to main content

memstead_base/engine/
file_watcher.rs

1//! Cross-process file-watcher convenience for the change-event
2//! surface.
3//!
4//! [`watch_mem_repo`] starts a `notify`-backed file-system watcher
5//! against `<gitdir>/refs/heads/` and surfaces a
6//! [`std::sync::mpsc::Receiver`] of [`MemChangedEvent`]s. Consumers
7//! that do not share the writer's [`crate::Engine`] instance (the
8//! bridge HEAD-watcher, the macOS live-update path, audit-log workers,
9//! webhook notifiers) consume events through this surface; the wire
10//! shape matches the in-process callback API exactly so the same
11//! downstream code paths work for both sources.
12//!
13//! Gated behind the `file-watcher` Cargo feature — `notify` is a
14//! non-trivial dependency and consumers that only need the in-process
15//! [`crate::Engine::subscribe_mem_changes`] path should not pay for
16//! it. Without the feature enabled the module disappears entirely.
17//!
18//! ## Design
19//!
20//! * `watch_mem_repo` spawns a background thread running the
21//!   notify-event loop and returns a [`MemRepoWatcher`] handle that
22//!   owns the `notify::PollWatcher` (chosen over `RecommendedWatcher`
23//!   for cross-platform determinism — see the rationale at the
24//!   `PollWatcher::new` call site). A per-mem
25//!   `HashMap<mem_name, last_seen_sha>` (a shared `Arc<Mutex<…>>`
26//!   seeded before the thread starts) lets each emitted event's
27//!   `previous` field reflect the actual transition.
28//! * On startup the thread scans the existing `refs/heads/` tree and
29//!   seeds the SHA map without emitting synthetic history events —
30//!   consumers see changes from this point forward, never replay.
31//! * Per file-system event the thread re-reads the touched ref file
32//!   (or the directory holding it for hierarchical layouts) and emits
33//!   a `MemChangedEvent` if the SHA changed. Notify can deliver
34//!   bursts; the SHA-comparison gate deduplicates them.
35//! * The returned [`MemRepoWatcher`] handle owns the notify watcher;
36//!   dropping it cancels the watch and the background thread joins on
37//!   its event channel disconnecting.
38//!
39//! ## Limitations
40//!
41//! * Reads loose refs only (the typical case for an actively-written
42//!   mem-repo). Packed refs (`<gitdir>/packed-refs`) are not parsed
43//!   in v1 — production deployments that compact refs need a
44//!   follow-up. Loose refs created after compaction still surface
45//!   normally.
46//! * Read-only consumers must poll [`crate::ops::changes_since`] for
47//!   any history that landed before the watcher started.
48
49use std::collections::HashMap;
50use std::path::{Path, PathBuf};
51use std::sync::mpsc::{Receiver, Sender, channel};
52use std::sync::{Arc, Mutex};
53use std::thread;
54use std::time::Duration;
55
56use notify::{Config, Event, PollWatcher, RecursiveMode, Watcher};
57
58use super::events::MemChangedEvent;
59
60/// Poll interval for the underlying `notify` watcher. Chosen so the
61/// observed change-event latency stays in the 10–50 ms band the AC
62/// targets (typical) while bounded by `< 1 s` (worst case). Higher
63/// values reduce CPU cost on idle workspaces; lower values shrink the
64/// observation window for active write loops. 50 ms is a comfortable
65/// middle ground for the v1 cross-process surface.
66const POLL_INTERVAL: Duration = Duration::from_millis(50);
67
68/// Errors surfaced by [`watch_mem_repo`].
69#[derive(Debug, thiserror::Error)]
70pub enum FileWatcherError {
71    /// `<gitdir>/refs/heads` does not exist. The mem-repo has not
72    /// been initialised, or the path was wrong.
73    #[error("refs/heads directory not found under gitdir: {0}")]
74    RefsHeadsMissing(PathBuf),
75    /// `notify` failed to start the underlying watcher (permission
76    /// denied, FS not supported on the platform, resource exhaustion).
77    #[error("notify error: {0}")]
78    Notify(#[from] notify::Error),
79    /// IO error while reading an existing ref file during the
80    /// initial SHA-map seeding pass.
81    #[error("io error reading initial refs/heads state: {0}")]
82    Io(#[from] std::io::Error),
83}
84
85/// RAII handle returned by [`watch_mem_repo`]. Dropping the handle
86/// stops the watcher (the underlying notify watcher is dropped,
87/// cancelling the OS-level subscription, and the background thread
88/// exits when its event channel disconnects).
89///
90/// The handle is `Send` but not `Sync` — the consumer threads receive
91/// events through the `mpsc::Receiver` returned alongside the handle,
92/// not by sharing the handle itself.
93pub struct MemRepoWatcher {
94    _watcher: PollWatcher,
95    // The background-thread join handle is kept so panics inside the
96    // event loop surface eventually (on drop the thread's panic
97    // propagates if the consumer joins). For v1 we let the OS reap
98    // the thread on watcher drop; the panic surfaces through tracing.
99    _thread: Option<thread::JoinHandle<()>>,
100}
101
102impl std::fmt::Debug for MemRepoWatcher {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("MemRepoWatcher").finish()
105    }
106}
107
108/// Start a file-system watcher on `<gitdir>/refs/heads/` and surface a
109/// receiver of [`MemChangedEvent`]s. See the module docs for the
110/// design and the limitations (loose-refs only, no synthetic replay).
111///
112/// `gitdir` is the bare-repo directory typically named `mem-repo`
113/// inside a Memstead workspace. Pass the path that contains the
114/// `refs/heads/` tree directly — the function does not auto-discover
115/// from a workspace root.
116pub fn watch_mem_repo(
117    gitdir: &Path,
118) -> Result<(MemRepoWatcher, Receiver<MemChangedEvent>), FileWatcherError> {
119    let refs_heads = gitdir.join("refs").join("heads");
120    if !refs_heads.is_dir() {
121        return Err(FileWatcherError::RefsHeadsMissing(refs_heads));
122    }
123
124    // Seed the per-mem SHA map from the current state of
125    // `refs/heads/` so the first emit per mem carries a correct
126    // `previous` value (instead of always empty).
127    let state: Arc<Mutex<HashMap<String, String>>> =
128        Arc::new(Mutex::new(scan_initial_state(&refs_heads)?));
129
130    let (event_tx, event_rx) = channel::<MemChangedEvent>();
131    let (notify_tx, notify_rx) = channel::<notify::Result<Event>>();
132
133    // Use `PollWatcher` rather than `RecommendedWatcher` for v1: it is
134    // deterministic across platforms (no FSEvents/inotify backend
135    // quirks under tempdir / sandbox / network volumes), the poll cost
136    // is negligible for the small `refs/heads/` tree of a typical
137    // mem-repo, and the 10–50 ms latency band the AC targets is
138    // achievable with a 50 ms interval. Production deployments that
139    // ever need lower latency can switch to `RecommendedWatcher`
140    // behind a config knob — left out of v1 to keep the surface small.
141    let mut watcher = PollWatcher::new(
142        move |res: notify::Result<Event>| {
143            // The notify thread sends results through `notify_tx`;
144            // ignore send failures (consumer thread already exited).
145            let _ = notify_tx.send(res);
146        },
147        Config::default()
148            .with_poll_interval(POLL_INTERVAL)
149            // Compare file contents rather than just mtime so writes
150            // within the same OS-level mtime tick (1 s on some
151            // filesystems / macOS HFS+ legacy paths) still surface as
152            // events. Cheap on the small `refs/heads/` tree.
153            .with_compare_contents(true),
154    )?;
155    watcher.watch(&refs_heads, RecursiveMode::Recursive)?;
156
157    let refs_heads_for_thread = refs_heads.clone();
158    let state_for_thread = state.clone();
159    let event_tx_for_thread = event_tx;
160    let join = thread::Builder::new()
161        .name("memstead-mem-repo-watcher".to_string())
162        .spawn(move || {
163            run_event_loop(
164                &refs_heads_for_thread,
165                state_for_thread,
166                notify_rx,
167                event_tx_for_thread,
168            );
169        })
170        .expect("spawning file-watcher thread must succeed");
171
172    Ok((
173        MemRepoWatcher {
174            _watcher: watcher,
175            _thread: Some(join),
176        },
177        event_rx,
178    ))
179}
180
181/// Walk the initial `refs/heads/` tree and read each loose ref's
182/// 40-char SHA. The map this returns seeds the per-mem state so the
183/// first event emitted for any mem carries the right `previous`
184/// value (rather than always an empty string).
185fn scan_initial_state(refs_heads: &Path) -> Result<HashMap<String, String>, std::io::Error> {
186    let mut out = HashMap::new();
187    scan_dir(refs_heads, refs_heads, &mut out)?;
188    Ok(out)
189}
190
191fn scan_dir(
192    base: &Path,
193    dir: &Path,
194    out: &mut HashMap<String, String>,
195) -> Result<(), std::io::Error> {
196    for entry in std::fs::read_dir(dir)? {
197        let entry = entry?;
198        let path = entry.path();
199        let file_type = entry.file_type()?;
200        if file_type.is_dir() {
201            scan_dir(base, &path, out)?;
202        } else if file_type.is_file()
203            && let Some(name) = mem_name_for_ref_path(base, &path)
204            && let Some(sha) = read_ref_sha(&path)
205        {
206            out.insert(name, sha);
207        }
208    }
209    Ok(())
210}
211
212/// Derive the mem name from a `refs/heads/<...>` path. Returns
213/// `None` when the path is outside `base` (defensive — should not
214/// happen in practice). Hierarchical layouts (`refs/heads/path/leaf`)
215/// produce `path/leaf` as the mem name; flat layouts (the typical
216/// case) produce the file basename.
217fn mem_name_for_ref_path(base: &Path, ref_path: &Path) -> Option<String> {
218    let rel = ref_path.strip_prefix(base).ok()?;
219    Some(
220        rel.components()
221            .filter_map(|c| c.as_os_str().to_str())
222            .collect::<Vec<_>>()
223            .join("/"),
224    )
225}
226
227/// Read a loose-ref file's content and parse it as a 40-character
228/// hex SHA. Returns `None` for unexpected shapes (symbolic refs like
229/// `ref: refs/heads/main`, empty files mid-write, content longer than
230/// 41 bytes). Loose refs in a healthy git repo are always either a
231/// raw SHA or a `ref:` line — symbolic refs in `refs/heads/` are not
232/// emitted as mem changes.
233fn read_ref_sha(ref_path: &Path) -> Option<String> {
234    let raw = std::fs::read_to_string(ref_path).ok()?;
235    let trimmed = raw.trim();
236    if trimmed.len() != 40 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
237        return None;
238    }
239    Some(trimmed.to_string())
240}
241
242/// Background-thread event loop. Reads notify events off `notify_rx`,
243/// re-reads the touched ref files to determine the new SHA, and emits
244/// a [`MemChangedEvent`] to `event_tx` whenever a SHA changes.
245fn run_event_loop(
246    refs_heads: &Path,
247    state: Arc<Mutex<HashMap<String, String>>>,
248    notify_rx: Receiver<notify::Result<Event>>,
249    event_tx: Sender<MemChangedEvent>,
250) {
251    while let Ok(item) = notify_rx.recv() {
252        let Ok(event) = item else { continue };
253        // No kind filter — `PollWatcher`'s event kinds are
254        // backend-defined and we re-read every touched file's SHA
255        // anyway. The dedupe gate on `(previous, new_sha)` below is
256        // what guarantees idempotent re-writes don't surface as
257        // changes. Remove-style events trip the early-exit when the
258        // path is no longer a file.
259        for path in event.paths {
260            if !path.is_file() {
261                continue;
262            }
263            let Some(mem) = mem_name_for_ref_path(refs_heads, &path) else {
264                continue;
265            };
266            let Some(new_sha) = read_ref_sha(&path) else {
267                continue;
268            };
269            let previous = {
270                let mut map = state.lock().unwrap();
271                let prev = map.get(&mem).cloned().unwrap_or_default();
272                if prev == new_sha {
273                    continue;
274                }
275                map.insert(mem.clone(), new_sha.clone());
276                prev
277            };
278            let emission = MemChangedEvent {
279                mem,
280                head: new_sha,
281                previous,
282                n_commits: 1,
283            };
284            if event_tx.send(emission).is_err() {
285                // Consumer dropped the receiver — nothing left to do.
286                return;
287            }
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use std::time::{Duration, Instant};
296    use tempfile::TempDir;
297
298    /// Wait up to `timeout` for an event whose mem matches
299    /// `expected_mem`. Drains and forwards any unrelated event the
300    /// watcher may emit during the wait (notify can fire for unrelated
301    /// fs noise inside the gitdir tree).
302    fn recv_event_for(
303        rx: &Receiver<MemChangedEvent>,
304        expected_mem: &str,
305        timeout: Duration,
306    ) -> Option<MemChangedEvent> {
307        let deadline = Instant::now() + timeout;
308        loop {
309            let remaining = deadline.checked_duration_since(Instant::now())?;
310            match rx.recv_timeout(remaining) {
311                Ok(ev) if ev.mem == expected_mem => return Some(ev),
312                Ok(_) => continue,
313                Err(_) => return None,
314            }
315        }
316    }
317
318    fn make_refs_heads(tmp: &TempDir) -> PathBuf {
319        let gitdir = tmp.path().join("mem-repo.git");
320        std::fs::create_dir_all(gitdir.join("refs").join("heads")).unwrap();
321        gitdir
322    }
323
324    fn write_ref(refs_heads: &Path, mem: &str, sha: &str) {
325        let p = refs_heads.join(mem);
326        if let Some(parent) = p.parent() {
327            std::fs::create_dir_all(parent).unwrap();
328        }
329        // Git writes refs as `<sha>\n`. Replicate that so the parser's
330        // trim normalises consistently with real-world inputs.
331        std::fs::write(p, format!("{sha}\n")).unwrap();
332    }
333
334    #[test]
335    fn refs_heads_missing_returns_typed_error() {
336        let tmp = TempDir::new().unwrap();
337        let err = watch_mem_repo(&tmp.path().join("nope")).unwrap_err();
338        match err {
339            FileWatcherError::RefsHeadsMissing(_) => {}
340            other => panic!("expected RefsHeadsMissing, got {other:?}"),
341        }
342    }
343
344    #[test]
345    fn modifying_ref_file_emits_mem_changed_event() {
346        let tmp = TempDir::new().unwrap();
347        let gitdir = make_refs_heads(&tmp);
348        let refs_heads = gitdir.join("refs").join("heads");
349
350        let initial = "1234567890abcdef1234567890abcdef12345678";
351        write_ref(&refs_heads, "specs", initial);
352
353        let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
354
355        // Update the ref to a new SHA — the watcher should fire.
356        let updated = "fedcba9876543210fedcba9876543210fedcba98";
357        write_ref(&refs_heads, "specs", updated);
358
359        let event = recv_event_for(&rx, "specs", Duration::from_secs(2))
360            .expect("event must arrive within 2s");
361        assert_eq!(event.mem, "specs");
362        assert_eq!(event.head, updated);
363        assert_eq!(event.previous, initial);
364        assert_eq!(event.n_commits, 1);
365    }
366
367    #[test]
368    fn creating_new_ref_file_emits_event_with_empty_previous() {
369        let tmp = TempDir::new().unwrap();
370        let gitdir = make_refs_heads(&tmp);
371        let refs_heads = gitdir.join("refs").join("heads");
372
373        let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
374
375        let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
376        write_ref(&refs_heads, "newmem", sha);
377
378        let event = recv_event_for(&rx, "newmem", Duration::from_secs(2))
379            .expect("event must arrive within 2s");
380        assert_eq!(event.head, sha);
381        assert_eq!(event.previous, "");
382    }
383
384    #[test]
385    fn idempotent_writes_do_not_emit_duplicate_events() {
386        let tmp = TempDir::new().unwrap();
387        let gitdir = make_refs_heads(&tmp);
388        let refs_heads = gitdir.join("refs").join("heads");
389
390        write_ref(
391            &refs_heads,
392            "specs",
393            "1111111111111111111111111111111111111111",
394        );
395        let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
396
397        // Re-write the same SHA — notify will fire, but the SHA gate
398        // in the event loop suppresses the duplicate emission.
399        write_ref(
400            &refs_heads,
401            "specs",
402            "1111111111111111111111111111111111111111",
403        );
404
405        // Wait briefly; no event for "specs" should arrive.
406        assert!(
407            recv_event_for(&rx, "specs", Duration::from_millis(200)).is_none(),
408            "idempotent re-write must not surface as a change event",
409        );
410    }
411
412    #[test]
413    fn hierarchical_branch_paths_produce_compound_mem_names() {
414        let tmp = TempDir::new().unwrap();
415        let gitdir = make_refs_heads(&tmp);
416        let refs_heads = gitdir.join("refs").join("heads");
417
418        let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
419
420        let sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
421        write_ref(&refs_heads, "team/specs", sha);
422
423        let event = recv_event_for(&rx, "team/specs", Duration::from_secs(2))
424            .expect("hierarchical event must arrive");
425        assert_eq!(event.mem, "team/specs");
426        assert_eq!(event.head, sha);
427    }
428
429    #[test]
430    fn dropping_watcher_stops_event_delivery() {
431        let tmp = TempDir::new().unwrap();
432        let gitdir = make_refs_heads(&tmp);
433        let refs_heads = gitdir.join("refs").join("heads");
434
435        let (watcher, rx) = watch_mem_repo(&gitdir).unwrap();
436        drop(watcher);
437
438        // After the watcher drops, new ref writes should not surface.
439        write_ref(
440            &refs_heads,
441            "specs",
442            "cccccccccccccccccccccccccccccccccccccccc",
443        );
444        assert!(
445            recv_event_for(&rx, "specs", Duration::from_millis(200)).is_none(),
446            "dropped watcher must not deliver further events",
447        );
448    }
449}