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}