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, a UI 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).
185/// A roster-file change, as the roster watcher reports it. Carries no
186/// diff: the consumer runs the engine's reconciliation (every operation
187/// does, and `memstead_reload` / the ui-api reload force it), which
188/// computes the applied change from the file.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct RosterFileChanged {
191    /// The roster file that moved (`<workspace>/.memstead/state/mounts.json`).
192    pub path: PathBuf,
193}
194
195/// Watch the workspace's mount roster (`.memstead/state/mounts.json`)
196/// alongside the mem-repo refs: one event per observed write, over the
197/// same polling watcher and cadence [`watch_mem_repo`] uses. The
198/// workspace store directory is watched (not the file), so a roster
199/// written by rename-into-place still surfaces. `RefsHeadsMissing` is
200/// reused for a workspace whose store directory does not exist.
201pub fn watch_roster(
202    workspace_root: &Path,
203) -> Result<(MemRepoWatcher, Receiver<RosterFileChanged>), FileWatcherError> {
204    let state_dir = workspace_root
205        .join(crate::workspace_store::WORKSPACE_STORE_DIR)
206        .join("state");
207    if !state_dir.is_dir() {
208        return Err(FileWatcherError::RefsHeadsMissing(state_dir));
209    }
210    let roster = state_dir.join("mounts.json");
211    let (event_tx, event_rx) = channel::<RosterFileChanged>();
212    let (notify_tx, notify_rx) = channel::<notify::Result<Event>>();
213    let mut watcher = PollWatcher::new(
214        move |res: notify::Result<Event>| {
215            let _ = notify_tx.send(res);
216        },
217        Config::default()
218            .with_poll_interval(POLL_INTERVAL)
219            .with_compare_contents(true),
220    )?;
221    watcher.watch(&state_dir, RecursiveMode::NonRecursive)?;
222    let join = thread::Builder::new()
223        .name("memstead-roster-watcher".to_string())
224        .spawn(move || {
225            while let Ok(res) = notify_rx.recv() {
226                let Ok(event) = res else { continue };
227                if event.paths.iter().any(|p| p == &roster)
228                    && event_tx
229                        .send(RosterFileChanged {
230                            path: roster.clone(),
231                        })
232                        .is_err()
233                {
234                    break;
235                }
236            }
237        })
238        .expect("spawning roster-watcher thread must succeed");
239    Ok((
240        MemRepoWatcher {
241            _watcher: watcher,
242            _thread: Some(join),
243        },
244        event_rx,
245    ))
246}
247
248fn scan_initial_state(refs_heads: &Path) -> Result<HashMap<String, String>, std::io::Error> {
249    let mut out = HashMap::new();
250    scan_dir(refs_heads, refs_heads, &mut out)?;
251    Ok(out)
252}
253
254fn scan_dir(
255    base: &Path,
256    dir: &Path,
257    out: &mut HashMap<String, String>,
258) -> Result<(), std::io::Error> {
259    for entry in std::fs::read_dir(dir)? {
260        let entry = entry?;
261        let path = entry.path();
262        let file_type = entry.file_type()?;
263        if file_type.is_dir() {
264            scan_dir(base, &path, out)?;
265        } else if file_type.is_file()
266            && let Some(name) = mem_name_for_ref_path(base, &path)
267            && let Some(sha) = read_ref_sha(&path)
268        {
269            out.insert(name, sha);
270        }
271    }
272    Ok(())
273}
274
275/// Derive the mem name from a `refs/heads/<...>` path. Returns
276/// `None` when the path is outside `base` (defensive — should not
277/// happen in practice). Hierarchical layouts (`refs/heads/path/leaf`)
278/// produce `path/leaf` as the mem name; flat layouts (the typical
279/// case) produce the file basename.
280fn mem_name_for_ref_path(base: &Path, ref_path: &Path) -> Option<String> {
281    let rel = ref_path.strip_prefix(base).ok()?;
282    Some(
283        rel.components()
284            .filter_map(|c| c.as_os_str().to_str())
285            .collect::<Vec<_>>()
286            .join("/"),
287    )
288}
289
290/// Read a loose-ref file's content and parse it as a 40-character
291/// hex SHA. Returns `None` for unexpected shapes (symbolic refs like
292/// `ref: refs/heads/main`, empty files mid-write, content longer than
293/// 41 bytes). Loose refs in a healthy git repo are always either a
294/// raw SHA or a `ref:` line — symbolic refs in `refs/heads/` are not
295/// emitted as mem changes.
296fn read_ref_sha(ref_path: &Path) -> Option<String> {
297    let raw = std::fs::read_to_string(ref_path).ok()?;
298    let trimmed = raw.trim();
299    if trimmed.len() != 40 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
300        return None;
301    }
302    Some(trimmed.to_string())
303}
304
305/// Background-thread event loop. Reads notify events off `notify_rx`,
306/// re-reads the touched ref files to determine the new SHA, and emits
307/// a [`MemChangedEvent`] to `event_tx` whenever a SHA changes.
308fn run_event_loop(
309    refs_heads: &Path,
310    state: Arc<Mutex<HashMap<String, String>>>,
311    notify_rx: Receiver<notify::Result<Event>>,
312    event_tx: Sender<MemChangedEvent>,
313) {
314    while let Ok(item) = notify_rx.recv() {
315        let Ok(event) = item else { continue };
316        // No kind filter — `PollWatcher`'s event kinds are
317        // backend-defined and we re-read every touched file's SHA
318        // anyway. The dedupe gate on `(previous, new_sha)` below is
319        // what guarantees idempotent re-writes don't surface as
320        // changes. Remove-style events trip the early-exit when the
321        // path is no longer a file.
322        for path in event.paths {
323            if !path.is_file() {
324                continue;
325            }
326            let Some(mem) = mem_name_for_ref_path(refs_heads, &path) else {
327                continue;
328            };
329            let Some(new_sha) = read_ref_sha(&path) else {
330                continue;
331            };
332            let previous = {
333                let mut map = state.lock().unwrap();
334                let prev = map.get(&mem).cloned().unwrap_or_default();
335                if prev == new_sha {
336                    continue;
337                }
338                map.insert(mem.clone(), new_sha.clone());
339                prev
340            };
341            let emission = MemChangedEvent {
342                mem,
343                head: new_sha,
344                previous,
345                n_commits: 1,
346            };
347            if event_tx.send(emission).is_err() {
348                // Consumer dropped the receiver — nothing left to do.
349                return;
350            }
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use std::time::{Duration, Instant};
359    use tempfile::TempDir;
360
361    /// Wait up to `timeout` for an event whose mem matches
362    /// `expected_mem`. Drains and forwards any unrelated event the
363    /// watcher may emit during the wait (notify can fire for unrelated
364    /// fs noise inside the gitdir tree).
365    fn recv_event_for(
366        rx: &Receiver<MemChangedEvent>,
367        expected_mem: &str,
368        timeout: Duration,
369    ) -> Option<MemChangedEvent> {
370        let deadline = Instant::now() + timeout;
371        loop {
372            let remaining = deadline.checked_duration_since(Instant::now())?;
373            match rx.recv_timeout(remaining) {
374                Ok(ev) if ev.mem == expected_mem => return Some(ev),
375                Ok(_) => continue,
376                Err(_) => return None,
377            }
378        }
379    }
380
381    fn make_refs_heads(tmp: &TempDir) -> PathBuf {
382        let gitdir = tmp.path().join("mem-repo.git");
383        std::fs::create_dir_all(gitdir.join("refs").join("heads")).unwrap();
384        gitdir
385    }
386
387    fn write_ref(refs_heads: &Path, mem: &str, sha: &str) {
388        let p = refs_heads.join(mem);
389        if let Some(parent) = p.parent() {
390            std::fs::create_dir_all(parent).unwrap();
391        }
392        // Git writes refs as `<sha>\n`. Replicate that so the parser's
393        // trim normalises consistently with real-world inputs.
394        std::fs::write(p, format!("{sha}\n")).unwrap();
395    }
396
397    #[test]
398    fn refs_heads_missing_returns_typed_error() {
399        let tmp = TempDir::new().unwrap();
400        let err = watch_mem_repo(&tmp.path().join("nope")).unwrap_err();
401        match err {
402            FileWatcherError::RefsHeadsMissing(_) => {}
403            other => panic!("expected RefsHeadsMissing, got {other:?}"),
404        }
405    }
406
407    #[test]
408    fn modifying_ref_file_emits_mem_changed_event() {
409        let tmp = TempDir::new().unwrap();
410        let gitdir = make_refs_heads(&tmp);
411        let refs_heads = gitdir.join("refs").join("heads");
412
413        let initial = "1234567890abcdef1234567890abcdef12345678";
414        write_ref(&refs_heads, "specs", initial);
415
416        let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
417
418        // Update the ref to a new SHA — the watcher should fire.
419        let updated = "fedcba9876543210fedcba9876543210fedcba98";
420        write_ref(&refs_heads, "specs", updated);
421
422        let event = recv_event_for(&rx, "specs", Duration::from_secs(2))
423            .expect("event must arrive within 2s");
424        assert_eq!(event.mem, "specs");
425        assert_eq!(event.head, updated);
426        assert_eq!(event.previous, initial);
427        assert_eq!(event.n_commits, 1);
428    }
429
430    #[test]
431    fn creating_new_ref_file_emits_event_with_empty_previous() {
432        let tmp = TempDir::new().unwrap();
433        let gitdir = make_refs_heads(&tmp);
434        let refs_heads = gitdir.join("refs").join("heads");
435
436        let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
437
438        let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
439        write_ref(&refs_heads, "newmem", sha);
440
441        let event = recv_event_for(&rx, "newmem", Duration::from_secs(2))
442            .expect("event must arrive within 2s");
443        assert_eq!(event.head, sha);
444        assert_eq!(event.previous, "");
445    }
446
447    #[test]
448    fn idempotent_writes_do_not_emit_duplicate_events() {
449        let tmp = TempDir::new().unwrap();
450        let gitdir = make_refs_heads(&tmp);
451        let refs_heads = gitdir.join("refs").join("heads");
452
453        write_ref(
454            &refs_heads,
455            "specs",
456            "1111111111111111111111111111111111111111",
457        );
458        let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
459
460        // Re-write the same SHA — notify will fire, but the SHA gate
461        // in the event loop suppresses the duplicate emission.
462        write_ref(
463            &refs_heads,
464            "specs",
465            "1111111111111111111111111111111111111111",
466        );
467
468        // Wait briefly; no event for "specs" should arrive.
469        assert!(
470            recv_event_for(&rx, "specs", Duration::from_millis(200)).is_none(),
471            "idempotent re-write must not surface as a change event",
472        );
473    }
474
475    #[test]
476    fn hierarchical_branch_paths_produce_compound_mem_names() {
477        let tmp = TempDir::new().unwrap();
478        let gitdir = make_refs_heads(&tmp);
479        let refs_heads = gitdir.join("refs").join("heads");
480
481        let (_watcher, rx) = watch_mem_repo(&gitdir).unwrap();
482
483        let sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
484        write_ref(&refs_heads, "team/specs", sha);
485
486        let event = recv_event_for(&rx, "team/specs", Duration::from_secs(2))
487            .expect("hierarchical event must arrive");
488        assert_eq!(event.mem, "team/specs");
489        assert_eq!(event.head, sha);
490    }
491
492    #[test]
493    fn dropping_watcher_stops_event_delivery() {
494        let tmp = TempDir::new().unwrap();
495        let gitdir = make_refs_heads(&tmp);
496        let refs_heads = gitdir.join("refs").join("heads");
497
498        let (watcher, rx) = watch_mem_repo(&gitdir).unwrap();
499        drop(watcher);
500
501        // After the watcher drops, new ref writes should not surface.
502        write_ref(
503            &refs_heads,
504            "specs",
505            "cccccccccccccccccccccccccccccccccccccccc",
506        );
507        assert!(
508            recv_event_for(&rx, "specs", Duration::from_millis(200)).is_none(),
509            "dropped watcher must not deliver further events",
510        );
511    }
512
513    /// A4: the roster file is watched alongside the refs — a write to
514    /// `.memstead/state/mounts.json` surfaces as one event.
515    #[test]
516    fn roster_watcher_reports_a_roster_write() {
517        let tmp = TempDir::new().unwrap();
518        let state = tmp.path().join(".memstead").join("state");
519        std::fs::create_dir_all(&state).unwrap();
520        std::fs::write(state.join("mounts.json"), b"{\"mounts\":[]}").unwrap();
521        let (_watcher, rx) = watch_roster(tmp.path()).unwrap();
522        std::thread::sleep(POLL_INTERVAL * 3);
523        std::fs::write(state.join("mounts.json"), b"{\"mounts\":[{\"mem\":\"x\"}]}").unwrap();
524        let ev = rx
525            .recv_timeout(Duration::from_secs(5))
526            .expect("the roster write is reported");
527        assert!(ev.path.ends_with("mounts.json"));
528        assert!(matches!(
529            watch_roster(&tmp.path().join("nope")),
530            Err(FileWatcherError::RefsHeadsMissing(_))
531        ));
532    }
533}