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}