hh_record/watcher.rs
1//! Recursive filesystem watcher (FR-1.4).
2//!
3//! Spawns a `notify` watcher on the session cwd and records `file_change`
4//! events through the shared writer. Honors a built-in ignore list, the
5//! root `.gitignore`, and caller-supplied extra patterns. File contents are
6//! stored in the blob store (BLAKE3-keyed, zstd); binary files record hashes
7//! only unless `--record-binary` is set.
8//!
9//! # SRS deviation (flagged): nested `.gitignore`
10//!
11//! FR-1.4 says "honoring `.gitignore`". This implementation honors the cwd's
12//! root `.gitignore` and the global gitignore, plus built-in + extra patterns,
13//! via a single `ignore::gitignore::Gitignore` matcher. It does **not** honor
14//! `.gitignore` files in subdirectories (the `ignore` crate does not expose a
15//! public path-based matcher for the full directory-ignore tree without
16//! `WalkBuilder`, which is walk-oriented, not event-oriented). For v0.1 this
17//! covers the common case (one root `.gitignore`); nested ignores are a
18//! roadmap refinement. See the decisions summary.
19//!
20//! A one-time recursive scan of `cwd` runs before the event loop starts, to
21//! give [`resolve_first_seen`] a baseline of pre-existing files — see its
22//! docs for why (a macOS FSEvents quirk misreports edits to pre-existing
23//! files as creations otherwise).
24
25use std::collections::{HashMap, HashSet};
26use std::io::Read;
27use std::path::{Path, PathBuf};
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::sync::{Arc, Mutex};
30use std::thread::JoinHandle;
31use std::time::{Duration, Instant};
32
33use hh_core::blob::BlobStore;
34use hh_core::event::{ChangeKind, Event, EventKind, FileChange};
35use hh_core::store::EventWriter;
36use ignore::gitignore::{Gitignore, GitignoreBuilder};
37use notify::{RecommendedWatcher, RecursiveMode, Watcher};
38
39/// Built-in ignore patterns (FR-1.4: `.git/`, `node_modules/`, `target/`).
40const BUILTIN_IGNORE: &[&str] = &[".git/", "node_modules/", "target/"];
41
42/// Panic-injection flag for the panic-hygiene test (`tests::mod
43/// panic_hygiene`). See the check in [`run_loop`]. Test-only: compiled out of
44/// every non-test build.
45#[cfg(test)]
46static INJECT_PANIC_FOR_TEST: AtomicBool = AtomicBool::new(false);
47
48/// Serializes tests that run a real [`run_loop`] on a worker thread. Test-only.
49///
50/// [`INJECT_PANIC_FOR_TEST`] is a process-global `static`, so a worker spawned
51/// by one test would observe a flag armed by a *different* concurrently-running
52/// test and panic at the wrong time — aborting before its own shutdown backstop
53/// runs and turning that test into a flaky "0 events" failure (the macOS-CI
54/// `events seen: []` flake in `watcher_captures_write_then_immediate_stop`).
55/// Tests that spawn a real long-running watcher take this mutex for their
56/// duration so the injection flag is only ever seen by the worker it was meant
57/// for. `const`-constructible since Rust 1.63, so fine at the 1.75 MSRV.
58#[cfg(test)]
59static REAL_RUN_LOOP_MUTEX: Mutex<()> = Mutex::new(());
60
61/// Coalescing window for editor-style double-writes (FR-1.4). Editors save a
62/// file as a burst of filesystem events within tens of milliseconds — e.g.
63/// write-content then touch-mtime, or write-then-rename-into-place producing
64/// two Modify events on the final path. A 100 ms window merges such a burst
65/// into a single `file_change` event while staying well below the cadence of a
66/// human's distinct saves (seconds apart). 100 ms also matches a familiar
67/// typing-debounce and is too short to visibly delay recording.
68const DEBOUNCE_WINDOW: Duration = Duration::from_millis(100);
69
70/// How often the worker loop wakes to flush due pending events. Kept well
71/// below [`DEBOUNCE_WINDOW`] so a pending change is processed promptly once its
72/// window elapses.
73const POLL_INTERVAL: Duration = Duration::from_millis(20);
74
75/// Shutdown grace drain: once `stop` is signaled (the child has exited), keep
76/// receiving filesystem events until the channel is quiet for this long with
77/// no arriving event, then stop. Catches in-flight events from backends that
78/// deliver asynchronously — notably macOS FSEvents, which coalesces deliveries
79/// through a daemon and can report a write AFTER the process that performed it
80/// has already exited. A prompt backend (Linux inotify) typically has nothing
81/// left to deliver, so it pays only this short quiet wait. See [`run_loop`].
82const GRACE_QUIET: Duration = Duration::from_millis(150);
83
84/// Hard cap on the shutdown grace drain ([`GRACE_QUIET`]). Bounds the extra
85/// latency a slow-delivering backend can add to `hh run` shutdown after the
86/// child has exited. The drain ends at [`GRACE_QUIET`] of silence in the
87/// common case, so this cap only bites a pathologically busy directory still
88/// emitting events long after the child is gone.
89const GRACE_MAX: Duration = Duration::from_secs(1);
90
91/// Options for the filesystem watcher (FR-1.4).
92#[derive(Debug, Clone)]
93pub struct WatchOptions {
94 /// Directory to watch recursively.
95 pub cwd: PathBuf,
96 /// Max file size to capture (bytes); larger files are skipped.
97 pub max_file_size: u64,
98 /// Whether to store binary file contents (default off).
99 pub record_binary: bool,
100 /// Extra ignore patterns extending built-in + `.gitignore`.
101 pub extra_ignore: Vec<String>,
102 /// Absolute paths under cwd to exclude (the halfhand data dir / db /
103 /// blobs when the agent records into its own data dir). Matched by
104 /// prefix so a whole tree can be excluded.
105 pub internal_exclude: Vec<PathBuf>,
106}
107
108/// A handle to the running watcher thread.
109pub struct WatcherHandle {
110 /// The worker thread; joins when the recorder stops the watcher.
111 pub thread: Option<JoinHandle<()>>,
112 stop: Arc<AtomicBool>,
113}
114
115impl WatcherHandle {
116 /// Signal the watcher to stop and join its thread. Best-effort: a panic
117 /// in the worker is swallowed (the recording still finalizes).
118 pub fn stop_and_join(mut self) {
119 self.stop.store(true, Ordering::Release);
120 if let Some(t) = self.thread.take() {
121 let _ = t.join();
122 }
123 }
124}
125
126/// Spawn the watcher thread. Returns `Ok(Some(handle))` on success and
127/// `Ok(None)` when filesystem watching could not be set up at all — the matcher
128/// could not be built, the notify watcher could not initialize, or even the
129/// cwd itself is unwatchable. In every one of those cases the recorder **does
130/// not abort**: it prints a single actionable stderr warning and continues
131/// without file-change recording (FR-1.5 best-effort: PTY + adapter capture
132/// proceed regardless). Only a failure to spawn the worker thread returns `Err`
133/// (a fatal OS-resource error).
134///
135/// A single recursive watch is tried first. If it fails — e.g. one unreadable
136/// subdir (`EACCES` on `/tmp/systemd-private-*`) makes `notify` reject the whole
137/// recursive call — we fall back to per-directory non-recursive watches,
138/// skipping unreadable and ignored directories, so one bad subtree no longer
139/// blacks out file recording for the entire session.
140///
141/// `start` is the session-start `Instant` used to compute event timestamps
142/// relative to session start (FR-1.3).
143#[allow(clippy::too_many_arguments)] // recorder wiring; a builder would be overkill here
144pub fn spawn_watcher(
145 opts: WatchOptions,
146 writer: Arc<Mutex<EventWriter>>,
147 blobs: Arc<BlobStore>,
148 session_id: String,
149 start: Instant,
150) -> crate::Result<Option<WatcherHandle>> {
151 let matcher = match build_matcher(&opts.cwd, &opts.extra_ignore) {
152 Ok(m) => m,
153 Err(e) => {
154 eprintln!(
155 "hh: warning: could not build ignore matcher ({e}); \
156 file-change recording disabled for this session (recording continues)"
157 );
158 return Ok(None);
159 }
160 };
161 let stop = Arc::new(AtomicBool::new(false));
162
163 let (ntx, nrx) = std::sync::mpsc::channel();
164 let mut watcher = match notify::recommended_watcher(
165 move |res: std::result::Result<notify::Event, notify::Error>| {
166 // Channel send fails only when the watcher thread exited; ignore.
167 let _ = ntx.send(res);
168 },
169 ) {
170 Ok(w) => w,
171 Err(e) => {
172 eprintln!(
173 "hh: warning: could not initialize filesystem watcher ({e}); \
174 file-change recording disabled for this session (recording continues)"
175 );
176 return Ok(None);
177 }
178 };
179
180 // Try one recursive watch; on failure fall back to per-directory watches,
181 // skipping unreadable / ignored subtrees (FR-1.4 best-effort).
182 let cwd_watched = match watcher.watch(&opts.cwd, RecursiveMode::Recursive) {
183 Ok(()) => true,
184 Err(e) => {
185 eprintln!(
186 "hh: warning: recursive watch of {} failed ({e}); \
187 falling back to per-directory watches \
188 (changes in unreadable or newly-created subdirectories may be missed)",
189 opts.cwd.display()
190 );
191 add_per_dir_watches(&mut watcher, &opts.cwd, &matcher, &opts.internal_exclude)
192 }
193 };
194 if !cwd_watched {
195 eprintln!(
196 "hh: warning: could not watch {} at all; file changes will not be \
197 recorded for this session (recording continues — run `hh doctor`)",
198 opts.cwd.display()
199 );
200 return Ok(None);
201 }
202
203 // Capture the startup baseline (path → content hash) synchronously on the
204 // caller's thread BEFORE spawning the worker. This makes "the watch is
205 // observing" a property that holds the instant `spawn_watcher` returns, so
206 // any file written afterwards — by the recorded child, or by a test — is
207 // guaranteed post-baseline and is never misclassified as a pre-existing
208 // file the backstop would then skip as "unchanged". Doing the scan on the
209 // worker thread instead left a window (wide in tests, narrow in prod)
210 // where a write that raced ahead of the scan was folded into the baseline
211 // and dropped — the macOS-CI `events seen: []` flake. The scan is
212 // best-effort (unreadable files/dirs are skipped); see [`scan_baseline_hashes`].
213 let mut existing: HashMap<PathBuf, String> = HashMap::new();
214 scan_baseline_hashes(
215 &opts.cwd,
216 &opts.cwd,
217 &matcher,
218 &opts.internal_exclude,
219 &opts,
220 &mut existing,
221 );
222
223 let stop_for_thread = Arc::clone(&stop);
224 let thread = std::thread::Builder::new()
225 .name("hh-fs-watcher".into())
226 .spawn(move || {
227 run_loop(
228 watcher,
229 nrx,
230 matcher,
231 opts,
232 writer,
233 blobs,
234 session_id,
235 start,
236 stop_for_thread,
237 existing,
238 );
239 })
240 .map_err(|e| crate::RecordError::Pty(format!("spawn watcher thread: {e}")))?;
241
242 Ok(Some(WatcherHandle {
243 thread: Some(thread),
244 stop,
245 }))
246}
247
248/// Fall back when a single recursive watch fails: walk the tree from `root`
249/// adding a non-recursive watch per directory, skipping unreadable (`EACCES`)
250/// and ignored (built-in / `.gitignore` / extra) directories. Returns `true` if
251/// at least `root` itself was watched, `false` if even the cwd is unwatchable
252/// (the caller then disables file-change recording with a warning).
253///
254/// Newly-created subdirectories are not watched in this fallback mode (the
255/// non-recursive watch does not auto-descend) — a known limitation called out in
256/// the fallback warning. Best-effort: a `read_dir` failure on a subtree is
257/// skipped with at most one warning rather than aborting the walk.
258fn add_per_dir_watches(
259 watcher: &mut RecommendedWatcher,
260 root: &Path,
261 matcher: &Gitignore,
262 internal_exclude: &[PathBuf],
263) -> bool {
264 let mut stack = vec![root.to_path_buf()];
265 let mut dirs_watched = 0usize;
266 let mut warned_skip = false;
267 while let Some(dir) = stack.pop() {
268 match watcher.watch(&dir, RecursiveMode::NonRecursive) {
269 Ok(()) => dirs_watched += 1,
270 Err(e) => {
271 if dir == root {
272 return false; // can't watch the cwd at all
273 }
274 if !warned_skip {
275 eprintln!(
276 "hh: warning: could not watch {} ({e}); \
277 skipping this directory and its children",
278 dir.display()
279 );
280 warned_skip = true;
281 }
282 continue; // don't descend into a directory we can't watch
283 }
284 }
285 let Ok(entries) = std::fs::read_dir(&dir) else {
286 continue;
287 };
288 for e in entries.flatten() {
289 let path = e.path();
290 if internal_exclude.iter().any(|excl| path.starts_with(excl)) {
291 continue;
292 }
293 let Ok(rel) = path.strip_prefix(root) else {
294 continue;
295 };
296 let Ok(ft) = e.file_type() else {
297 continue;
298 };
299 if !ft.is_dir() {
300 continue;
301 }
302 if matcher.matched_path_or_any_parents(rel, true).is_ignore() {
303 continue;
304 }
305 stack.push(path);
306 }
307 }
308 dirs_watched > 0
309}
310
311/// A self-cleaning temporary directory for the watcher smoke test. `Drop`
312/// removes it (best-effort) so `watcher_smoke_test` leaves nothing behind even
313/// on an early return.
314struct TempProbe(PathBuf);
315
316impl Drop for TempProbe {
317 fn drop(&mut self) {
318 let _ = std::fs::remove_dir_all(&self.0);
319 }
320}
321
322/// Self-contained filesystem-watcher smoke test for `hh doctor`: create a
323/// temporary directory, watch it, write a file inside it, and confirm the
324/// watcher delivers at least one event within a short deadline. Returns
325/// `Ok(())` if an event arrived and `Err(message)` otherwise (notify init
326/// failed, the watch could not be added, the watcher reported an error, or no
327/// event arrived in time). This exercises the same `notify` backend the
328/// recorder uses, so a failure here explains why a session recorded 0 file
329/// changes despite files changing. Read-only with respect to the user's data
330/// dir — it writes only under a throwaway temp directory it cleans up itself.
331#[allow(clippy::missing_errors_doc)] // returns an ad-hoc reason string, not a typed error
332pub fn watcher_smoke_test() -> std::result::Result<(), String> {
333 use std::sync::mpsc;
334 // A unique-ish name from pid + a nanosecond stamp (no tempfile dependency
335 // in the binary; `std::env::temp_dir` is enough for a one-shot probe).
336 let stamp = std::time::SystemTime::now()
337 .duration_since(std::time::UNIX_EPOCH)
338 .map_or(0, |d| d.as_nanos());
339 let root =
340 std::env::temp_dir().join(format!("hh-doctor-smoke-{}-{}", std::process::id(), stamp));
341 std::fs::create_dir_all(&root).map_err(|e| format!("create temp dir: {e}"))?;
342 // Declared before `watcher` so it drops last: the watch is released before
343 // the temp directory is removed.
344 let _probe = TempProbe(root.clone());
345
346 let (tx, rx) = mpsc::channel();
347 let mut watcher = notify::recommended_watcher(move |res| {
348 let _ = tx.send(res);
349 })
350 .map_err(|e| format!("notify init failed: {e}"))?;
351
352 watcher
353 .watch(&root, RecursiveMode::Recursive)
354 .map_err(|e| format!("watch failed: {e}"))?;
355
356 std::fs::write(root.join("probe.txt"), b"hh doctor smoke test")
357 .map_err(|e| format!("write probe file: {e}"))?;
358
359 let outcome = match rx.recv_timeout(Duration::from_secs(2)) {
360 Ok(Ok(_event)) => Ok(()),
361 Ok(Err(e)) => Err(format!("watcher delivered an error: {e}")),
362 Err(mpsc::RecvTimeoutError::Timeout) => Err(
363 "no file-change event within 2s — the watcher is not delivering events on this platform"
364 .to_string(),
365 ),
366 Err(mpsc::RecvTimeoutError::Disconnected) => {
367 Err("watcher channel closed before any event".to_string())
368 }
369 };
370 // Drop the watcher to release the inotify/FSEvents handle before the guard
371 // removes the directory.
372 drop(watcher);
373 outcome
374}
375
376/// Build a single `Gitignore` matcher from built-in + root `.gitignore` +
377/// extra patterns, rooted at `cwd`.
378fn build_matcher(cwd: &Path, extra_ignore: &[String]) -> std::result::Result<Gitignore, String> {
379 let mut builder = GitignoreBuilder::new(cwd);
380 for line in BUILTIN_IGNORE {
381 builder
382 .add_line(None, line)
383 .map_err(|e| format!("built-in ignore `{line}`: {e}"))?;
384 }
385 let root_gitignore = cwd.join(".gitignore");
386 if root_gitignore.is_file() {
387 // `GitignoreBuilder::add` loads a file's lines as patterns, returning
388 // `Option<Error>` (a malformed line). Non-fatal: warn and continue.
389 if let Some(e) = builder.add(&root_gitignore) {
390 eprintln!(
391 "hh: warning: failed to read {}: {e}",
392 root_gitignore.display()
393 );
394 }
395 }
396 for line in extra_ignore {
397 builder
398 .add_line(None, line)
399 .map_err(|e| format!("extra ignore `{line}`: {e}"))?;
400 }
401 builder.build().map_err(|e| format!("build gitignore: {e}"))
402}
403
404/// The watcher event loop, run on the worker thread. Events are not processed
405/// immediately: each capturable path enters a [`pending`] map keyed by absolute
406/// path, and is processed only after its debounce window elapses with no
407/// further event on the same path (FR-1.4). On shutdown, any still-pending
408/// change is flushed so a quick exit doesn't drop a write that landed inside
409/// the window.
410// recorder wiring; values are moved into the spawning closure and owned for
411// the thread's lifetime (thread entry point, see runner::run_reader).
412#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] // see comment above
413fn run_loop(
414 watcher: RecommendedWatcher,
415 nrx: std::sync::mpsc::Receiver<std::result::Result<notify::Event, notify::Error>>,
416 matcher: Gitignore,
417 opts: WatchOptions,
418 writer: Arc<Mutex<EventWriter>>,
419 blobs: Arc<BlobStore>,
420 session_id: String,
421 start: Instant,
422 stop: Arc<AtomicBool>,
423 // Baseline of files (path → startup content hash) that existed under `cwd`
424 // before the watch started observing, captured synchronously in
425 // [`spawn_watcher`] on the caller's thread *before* the worker is spawned
426 // — so anything written after `spawn_watcher` returns is post-baseline and
427 // never misclassified as a pre-existing file. See [`spawn_watcher`] and
428 // [`rescan_for_missed_changes`] for how this baseline is used (spurious-
429 // create correction + missed-modify detection). The baseline content is
430 // NOT stored as a blob (no refcount for `before_hash`); only its hash is
431 // kept, so a backstopped modify renders with a missing before-side ("all
432 // added") — the original was overwritten before we observed it.
433 mut existing: HashMap<PathBuf, String>,
434) {
435 let mut known: HashMap<PathBuf, String> = HashMap::new();
436 let mut pending: HashMap<PathBuf, Pending> = HashMap::new();
437 while !stop.load(Ordering::Acquire) {
438 // Panic-hygiene test hook (never compiled outside `cfg(test)`): lets a
439 // test flip a flag and observe that a real panic on this thread is
440 // contained by `stop_and_join`'s `let _ = t.join()` rather than
441 // aborting the process or leaving the recording unable to finalize.
442 #[cfg(test)]
443 assert!(
444 !INJECT_PANIC_FOR_TEST.swap(false, Ordering::SeqCst),
445 "hh-record test: injected watcher panic"
446 );
447 match nrx.recv_timeout(POLL_INTERVAL) {
448 Ok(Ok(event)) => fold_event(&event, &matcher, &opts, &mut existing, &mut pending),
449 Ok(Err(e)) => eprintln!("hh: warning: fs watcher error: {e}"),
450 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
451 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
452 }
453 // Flush any pending changes whose debounce window has elapsed.
454 flush_due(
455 &mut pending,
456 &opts,
457 &writer,
458 &blobs,
459 &session_id,
460 start,
461 &mut known,
462 );
463 }
464 // --- Shutdown grace drain (FR-1.4 robustness) ------------------------
465 // `stop` was signaled: the child has exited and the recorder is shutting
466 // down. But the OS filesystem event source may still have in-flight
467 // events — notably macOS FSEvents, which coalesces deliveries through a
468 // daemon and can report a write AFTER the process that performed it has
469 // already exited. Stopping immediately (the old behavior) dropped those
470 // late events, so a quick-exiting agent could finalize with "0 file
471 // changes" even though it wrote files. Drain for a bounded window
472 // instead: keep receiving until the channel is quiet for [`GRACE_QUIET`]
473 // (caught up) or [`GRACE_MAX`] elapses (hard cap), whichever is first. A
474 // prompt backend (Linux inotify, events already delivered) pays only the
475 // short quiet wait.
476 let drain_start = Instant::now();
477 loop {
478 let cap_left = GRACE_MAX
479 .checked_sub(drain_start.elapsed())
480 .unwrap_or(Duration::ZERO);
481 if cap_left.is_zero() {
482 break;
483 }
484 let wait = if GRACE_QUIET < cap_left {
485 GRACE_QUIET
486 } else {
487 cap_left
488 };
489 match nrx.recv_timeout(wait) {
490 Ok(Ok(event)) => fold_event(&event, &matcher, &opts, &mut existing, &mut pending),
491 Ok(Err(e)) => eprintln!("hh: warning: fs watcher error: {e}"),
492 // Quiet for `wait` with no event, or channel closed → caught up.
493 Err(
494 std::sync::mpsc::RecvTimeoutError::Timeout
495 | std::sync::mpsc::RecvTimeoutError::Disconnected,
496 ) => break,
497 }
498 flush_due(
499 &mut pending,
500 &opts,
501 &writer,
502 &blobs,
503 &session_id,
504 start,
505 &mut known,
506 );
507 }
508 // Flush whatever is still pending so a write that landed inside the window
509 // just before shutdown is not lost.
510 flush_all(
511 &mut pending,
512 &opts,
513 &writer,
514 &blobs,
515 &session_id,
516 start,
517 &mut known,
518 );
519 // Deterministic backstop: re-walk cwd for any file the watcher never
520 // observed (the grace drain only helps when an event is *late*; this
521 // catches the case where the backend delivered *nothing* — e.g. macOS
522 // FSEvents on CI runners, observed flaky/absent). Files already in `known`
523 // (captured via the event path) or unchanged from the `existing` baseline
524 // are skipped, so this never duplicates a normal-path capture. A pre-
525 // existing file whose content hash differs from its baseline is recorded as
526 // a missed `Modified`.
527 rescan_for_missed_changes(
528 &opts,
529 &matcher,
530 &writer,
531 &blobs,
532 &session_id,
533 start,
534 &mut known,
535 &existing,
536 );
537 // Drop the watcher to release the inotify/fsevents handle.
538 drop(watcher);
539}
540
541/// Fold a raw `notify` event into the debounced `pending` map: classify each
542/// path against the ignore matcher, drop spurious first-seen "creates" on
543/// pre-existing files, and coalesce. Shared by the live loop and the shutdown
544/// grace drain so the two stay in sync.
545fn fold_event(
546 event: ¬ify::Event,
547 matcher: &Gitignore,
548 opts: &WatchOptions,
549 existing: &mut HashMap<PathBuf, String>,
550 pending: &mut HashMap<PathBuf, Pending>,
551) {
552 for path in &event.paths {
553 if let Some(kind) = classify(path, event.kind, matcher, opts) {
554 let kind = resolve_first_seen(kind, path, existing);
555 coalesce(pending, path.clone(), kind);
556 }
557 }
558}
559
560/// Final shutdown backstop (FR-1.4 robustness): re-walk `cwd` and record any
561/// capturable file change the watcher never observed.
562///
563/// This is deterministic and does **not** depend on the OS event source
564/// delivering anything. It is the safety net for backends that drop or never
565/// deliver events — notably macOS FSEvents, which is observed flaky/absent on
566/// GitHub's macOS runners (a quick-exiting agent can write a file and exit
567/// before `fseventsd` reports anything, and the grace drain only helps when
568/// the event is merely *late*, not *absent*). Without this backstop such a
569/// session finalizes with "0 file changes" and `hh inspect --diff` reports
570/// "no file changes in this session" despite files being written.
571///
572/// For each capturable file currently on disk:
573/// - in `known` → already recorded via the event path, skip (no duplicate);
574/// - in `existing` (present at baseline) → compare its current content hash
575/// to the baseline hash captured at startup. Unchanged → skip. Changed →
576/// record as a missed `Modified`, seeding `known[path] = baseline_hash` so
577/// [`process`] records `before = baseline`, `after = current`. The baseline
578/// blob was never stored (only its hash was kept), so the diff before-side
579/// is missing and the change renders as "all added" — the original content
580/// was overwritten before we observed it, so there is nothing to diff
581/// against. This is the one cosmetic gap of the hash-only baseline; it is
582/// documented and preferable to dropping the change entirely;
583/// - not in `known` and not in `existing` → created during the session with no
584/// event observed → record as `Created`.
585///
586/// Files larger than `max_file_size` are never capturable, so they are skipped
587/// by the hash comparison (and [`process`] would skip them anyway).
588#[allow(clippy::too_many_arguments)] // recorder wiring threaded through to the writer
589fn rescan_for_missed_changes(
590 opts: &WatchOptions,
591 matcher: &Gitignore,
592 writer: &Arc<Mutex<EventWriter>>,
593 blobs: &Arc<BlobStore>,
594 session_id: &str,
595 start: Instant,
596 known: &mut HashMap<PathBuf, String>,
597 existing: &HashMap<PathBuf, String>,
598) {
599 let mut current: HashSet<PathBuf> = HashSet::new();
600 scan_existing_files(
601 &opts.cwd,
602 &opts.cwd,
603 matcher,
604 &opts.internal_exclude,
605 &mut current,
606 );
607 for path in current {
608 if known.contains_key(&path) {
609 continue;
610 }
611 if let Some(baseline_hash) = existing.get(&path) {
612 // Baseline file: detect a missed modify by comparing the current
613 // content hash to the startup baseline hash. Unchanged → skip.
614 let Some(current_hash) = read_hash_if_capturable(&path, opts) else {
615 continue;
616 };
617 if current_hash == *baseline_hash {
618 continue;
619 }
620 // Modified during the session but the event was missed. Seed
621 // `known` with the baseline hash so `process` records
622 // `before = baseline`, `after = current`.
623 known.insert(path.clone(), baseline_hash.clone());
624 if let Err(e) = process(
625 &path,
626 ChangeKind::Modified,
627 opts,
628 writer,
629 blobs,
630 session_id,
631 start,
632 known,
633 ) {
634 eprintln!(
635 "hh: warning: file change capture failed for {}: {e}",
636 path.display()
637 );
638 }
639 } else {
640 // Not in the baseline and not already recorded → created during
641 // the session with no event observed.
642 if let Err(e) = process(
643 &path,
644 ChangeKind::Created,
645 opts,
646 writer,
647 blobs,
648 session_id,
649 start,
650 known,
651 ) {
652 eprintln!(
653 "hh: warning: file change capture failed for {}: {e}",
654 path.display()
655 );
656 }
657 }
658 }
659}
660
661/// Read `path` and return its BLAKE3 content hash, or `None` if it is not
662/// capturable (missing, larger than `max_file_size`, or unreadable). Used by
663/// the shutdown backstop to compare a baseline file's current content against
664/// its startup hash without storing a blob.
665fn read_hash_if_capturable(path: &Path, opts: &WatchOptions) -> Option<String> {
666 let meta = std::fs::metadata(path).ok()?;
667 if meta.len() > opts.max_file_size {
668 return None;
669 }
670 let mut f = std::fs::File::open(path).ok()?;
671 let mut content = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
672 f.read_to_end(&mut content).ok()?;
673 Some(BlobStore::hash(&content))
674}
675
676/// A debounced, not-yet-processed change to one path. `suppress` is set when a
677/// create was followed by a delete within the same window (net no-op).
678#[derive(Debug, Clone, Copy)]
679struct Pending {
680 /// Merged change kind to record once the window elapses.
681 kind: ChangeKind,
682 /// Deadline after which the change may be flushed.
683 deadline: Instant,
684 /// If `true`, drop the change on flush (net no-op within the window).
685 suppress: bool,
686}
687
688/// Flush every pending change whose deadline has passed.
689fn flush_due(
690 pending: &mut HashMap<PathBuf, Pending>,
691 opts: &WatchOptions,
692 writer: &Arc<Mutex<EventWriter>>,
693 blobs: &Arc<BlobStore>,
694 session_id: &str,
695 start: Instant,
696 known: &mut HashMap<PathBuf, String>,
697) {
698 let now = Instant::now();
699 let due: Vec<PathBuf> = pending
700 .iter()
701 .filter(|(_, p)| p.deadline <= now)
702 .map(|(k, _)| k.clone())
703 .collect();
704 for path in due {
705 flush_one(
706 pending, &path, opts, writer, blobs, session_id, start, known,
707 );
708 }
709}
710
711/// Flush every pending change, regardless of deadline (shutdown path).
712fn flush_all(
713 pending: &mut HashMap<PathBuf, Pending>,
714 opts: &WatchOptions,
715 writer: &Arc<Mutex<EventWriter>>,
716 blobs: &Arc<BlobStore>,
717 session_id: &str,
718 start: Instant,
719 known: &mut HashMap<PathBuf, String>,
720) {
721 let paths: Vec<PathBuf> = pending.keys().cloned().collect();
722 for path in paths {
723 flush_one(
724 pending, &path, opts, writer, blobs, session_id, start, known,
725 );
726 }
727}
728
729/// Remove and process one pending change, logging a warning on failure.
730#[allow(clippy::too_many_arguments)] // recorder wiring threaded through to the writer
731fn flush_one(
732 pending: &mut HashMap<PathBuf, Pending>,
733 path: &Path,
734 opts: &WatchOptions,
735 writer: &Arc<Mutex<EventWriter>>,
736 blobs: &Arc<BlobStore>,
737 session_id: &str,
738 start: Instant,
739 known: &mut HashMap<PathBuf, String>,
740) {
741 let Some(p) = pending.remove(path) else {
742 return;
743 };
744 if p.suppress {
745 return;
746 }
747 if let Err(e) = process(path, p.kind, opts, writer, blobs, session_id, start, known) {
748 eprintln!(
749 "hh: warning: file change capture failed for {}: {e}",
750 path.display()
751 );
752 }
753}
754
755/// Decide whether a raw notify event on `path` should be captured, and if so
756/// return the [`ChangeKind`]. Filters internal halfhand paths, paths outside
757/// the watched tree, directories, and ignored paths (built-in, `.gitignore`,
758/// and caller-supplied extra patterns). This runs *before* debouncing so
759/// ignored files never enter the pending map.
760fn classify(
761 path: &Path,
762 kind: notify::EventKind,
763 matcher: &Gitignore,
764 opts: &WatchOptions,
765) -> Option<ChangeKind> {
766 for excl in &opts.internal_exclude {
767 if path.starts_with(excl) {
768 return None;
769 }
770 }
771 let rel = path.strip_prefix(&opts.cwd).ok()?;
772 if path.is_dir() {
773 return None;
774 }
775 if matcher.matched_path_or_any_parents(rel, false).is_ignore() {
776 return None;
777 }
778 Some(match kind {
779 notify::EventKind::Create(_) => ChangeKind::Created,
780 notify::EventKind::Modify(_) => ChangeKind::Modified,
781 notify::EventKind::Remove(_) => ChangeKind::Deleted,
782 // Any/Other/Access are not content changes we capture.
783 notify::EventKind::Any | notify::EventKind::Other | notify::EventKind::Access(_) => {
784 return None;
785 }
786 })
787}
788
789/// Recursively collect every regular file under `dir` (relative to `cwd`)
790/// that isn't excluded or ignored, into `out`. Walked once at watch startup
791/// to build the baseline for [`resolve_first_seen`]. Does not follow
792/// symlinks (`DirEntry::file_type` reports the link itself, not its target),
793/// which also avoids symlink-cycle recursion. Best-effort: an unreadable
794/// subdirectory is skipped rather than failing the whole scan.
795fn scan_existing_files(
796 dir: &Path,
797 cwd: &Path,
798 matcher: &Gitignore,
799 internal_exclude: &[PathBuf],
800 out: &mut HashSet<PathBuf>,
801) {
802 let Ok(entries) = std::fs::read_dir(dir) else {
803 return;
804 };
805 for entry in entries.flatten() {
806 let path = entry.path();
807 if internal_exclude.iter().any(|excl| path.starts_with(excl)) {
808 continue;
809 }
810 let Ok(rel) = path.strip_prefix(cwd) else {
811 continue;
812 };
813 let Ok(file_type) = entry.file_type() else {
814 continue;
815 };
816 let is_dir = file_type.is_dir();
817 if matcher.matched_path_or_any_parents(rel, is_dir).is_ignore() {
818 continue;
819 }
820 if is_dir {
821 scan_existing_files(&path, cwd, matcher, internal_exclude, out);
822 } else {
823 out.insert(path);
824 }
825 }
826}
827
828/// Like [`scan_existing_files`] but records each capturable file's startup
829/// content hash (BLAKE3) into `out`, not just its path. Run once at watch
830/// startup to build the baseline that [`rescan_for_missed_changes`] diffs
831/// against at shutdown. The baseline content itself is NOT stored as a blob —
832/// only its hash — so a backstopped modify renders with a missing before-side.
833///
834/// Files larger than `max_file_size` are never capturable, so they are skipped
835/// here (no point hashing a file the watcher would never record). Best-effort:
836/// an unreadable subdirectory or an unreadable file (permissions, race) is
837/// skipped rather than failing the whole scan — a missing baseline entry just
838/// means that file, if later modified, is backstopped as a `Created` rather
839/// than a `Modified`, which is a safe over-report.
840fn scan_baseline_hashes(
841 dir: &Path,
842 cwd: &Path,
843 matcher: &Gitignore,
844 internal_exclude: &[PathBuf],
845 opts: &WatchOptions,
846 out: &mut HashMap<PathBuf, String>,
847) {
848 let Ok(entries) = std::fs::read_dir(dir) else {
849 return;
850 };
851 for entry in entries.flatten() {
852 let path = entry.path();
853 if internal_exclude.iter().any(|excl| path.starts_with(excl)) {
854 continue;
855 }
856 let Ok(rel) = path.strip_prefix(cwd) else {
857 continue;
858 };
859 let Ok(file_type) = entry.file_type() else {
860 continue;
861 };
862 let is_dir = file_type.is_dir();
863 if matcher.matched_path_or_any_parents(rel, is_dir).is_ignore() {
864 continue;
865 }
866 if is_dir {
867 scan_baseline_hashes(&path, cwd, matcher, internal_exclude, opts, out);
868 } else {
869 // Skip oversized files (the watcher would never capture them, so a
870 // baseline hash for them is useless I/O). A metadata error (file
871 // raced away, unreadable) is silently dropped — see the doc comment.
872 if let Ok(meta) = std::fs::metadata(&path) {
873 if meta.len() > opts.max_file_size {
874 continue;
875 }
876 if let Ok(mut f) = std::fs::File::open(&path) {
877 let mut content = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
878 if f.read_to_end(&mut content).is_ok() {
879 out.insert(path, BlobStore::hash(&content));
880 }
881 }
882 }
883 }
884 }
885}
886
887/// Resolve a raw classified [`ChangeKind`] against the set of paths already
888/// known to exist, correcting for a macOS FSEvents quirk: `fseventsd` can tag
889/// the *first-ever* notification it delivers for a path with
890/// `kFSEventStreamEventFlagItemCreated` even when the path predates the watch
891/// (it keeps no baseline of what existed before the stream started). Left
892/// uncorrected, editing a pre-existing file is recorded as `created` instead
893/// of `modified` (its very first observed event wins the merge in
894/// [`merge_kind`]).
895///
896/// `existing` maps a baseline path to its startup content hash. It is updated
897/// as change kinds are resolved, so a later delete-then-recreate of the same
898/// path within the same session is still reported as a genuine `Created`. A
899/// pre-existing baseline hash is preserved across a `Created`→`Modified`
900/// downgrade (it is not overwritten) so the shutdown backstop can still
901/// diff against it; a genuinely new path is inserted with an empty sentinel
902/// hash (no baseline) purely to mark it seen — the backstop skips any path
903/// already in `known`, so the sentinel is never compared.
904fn resolve_first_seen(
905 kind: ChangeKind,
906 path: &Path,
907 existing: &mut HashMap<PathBuf, String>,
908) -> ChangeKind {
909 match kind {
910 ChangeKind::Created if existing.contains_key(path) => ChangeKind::Modified,
911 ChangeKind::Created | ChangeKind::Modified => {
912 // Mark the path seen so a later raw `Created` (the FSEvents quirk
913 // firing again) downgrades to `Modified`. `or_default` preserves a
914 // pre-existing baseline hash (set at startup) for the backstop.
915 existing.entry(path.to_path_buf()).or_default();
916 kind
917 }
918 ChangeKind::Deleted => {
919 existing.remove(path);
920 ChangeKind::Deleted
921 }
922 }
923}
924
925/// Fold a new event on `path` into the pending map, extending its debounce
926/// window and merging kinds. A create followed by a delete within the window
927/// is a net no-op and is marked `suppress`.
928fn coalesce(pending: &mut HashMap<PathBuf, Pending>, path: PathBuf, kind: ChangeKind) {
929 let now = Instant::now();
930 let deadline = now + DEBOUNCE_WINDOW;
931 match pending.get_mut(&path) {
932 None => {
933 pending.insert(
934 path,
935 Pending {
936 kind,
937 deadline,
938 suppress: false,
939 },
940 );
941 }
942 Some(p) => {
943 p.deadline = deadline;
944 let (merged, suppress) = merge_kind(p.kind, kind);
945 p.kind = merged;
946 p.suppress = p.suppress || suppress;
947 }
948 }
949}
950
951/// Merge a prior pending kind with a newly-observed kind on the same path
952/// within one debounce window. Returns `(merged_kind, suppress)`.
953///
954/// Rules:
955/// - Create → Delete (same window): net no-op → `suppress`.
956/// - Anything → Created: the path exists now → `Created` (covers delete-then-
957/// recreate, which is rare within 100 ms but handled sensibly).
958/// - Modify after a Create stays `Created` (still the first observation).
959/// - Otherwise the latest kind wins, except two Modifies stay `Modified`.
960fn merge_kind(prior: ChangeKind, new: ChangeKind) -> (ChangeKind, bool) {
961 match (prior, new) {
962 // Create then Delete within the window is a net no-op → suppress.
963 (ChangeKind::Created, ChangeKind::Deleted) => (ChangeKind::Deleted, true),
964 // A (re)create means the path exists now; a modify right after the
965 // initial create is still the first observation of the file.
966 (ChangeKind::Created, ChangeKind::Modified) | (_, ChangeKind::Created) => {
967 (ChangeKind::Created, false)
968 }
969 // A delete wins over a prior modify/observe — including a Modify
970 // observed after a Delete, which is stale/spurious (the path can't
971 // be modified once it doesn't exist). macOS FSEvents can coalesce a
972 // whole create+modify+delete history into one notification batch and
973 // deliver it in `translate_flags`' fixed flag-check order rather than
974 // true chronological order (e.g. Created, Removed, ..., Modified);
975 // ground truth wins, so once deleted, stays deleted.
976 (_, ChangeKind::Deleted) | (ChangeKind::Deleted, ChangeKind::Modified) => {
977 (ChangeKind::Deleted, false)
978 }
979 // Two modifies (and any other combo) collapse to a single modify.
980 _ => (ChangeKind::Modified, false),
981 }
982}
983
984/// Process one debounced change into a `file_change` event (or skip it). The
985/// change kind is already known (computed by [`classify`], possibly merged by
986/// [`coalesce`]); this reads content, manages before/after hashes, and appends
987/// the event + `file_changes` row.
988#[allow(clippy::too_many_arguments)] // recorder wiring threaded through to the writer
989fn process(
990 path: &Path,
991 change_kind: ChangeKind,
992 opts: &WatchOptions,
993 writer: &Arc<Mutex<EventWriter>>,
994 blobs: &Arc<BlobStore>,
995 session_id: &str,
996 start: Instant,
997 known: &mut HashMap<PathBuf, String>,
998) -> std::result::Result<(), String> {
999 // Defensive: re-strip in case a path outside cwd slipped through.
1000 let Ok(rel) = path.strip_prefix(&opts.cwd) else {
1001 return Ok(());
1002 };
1003 let ts_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
1004 let rel_str = rel.to_string_lossy().to_string();
1005
1006 let (before_hash, after_hash, blob_hash, blob_size, is_binary) =
1007 if change_kind == ChangeKind::Deleted {
1008 let before = known.remove(path);
1009 (before, None, None, None, false)
1010 } else {
1011 // Create / Modify: read current content if within size limit.
1012 let meta = match std::fs::metadata(path) {
1013 Ok(m) => m,
1014 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1015 Err(e) => return Err(format!("stat {}: {e}", path.display())),
1016 };
1017 if meta.len() > opts.max_file_size {
1018 // FR-1.4: only capture files ≤ max_file_size.
1019 return Ok(());
1020 }
1021 let mut content = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
1022 let mut f = match std::fs::File::open(path) {
1023 Ok(f) => f,
1024 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1025 Err(e) => return Err(format!("open {}: {e}", path.display())),
1026 };
1027 f.read_to_end(&mut content)
1028 .map_err(|e| format!("read {}: {e}", path.display()))?;
1029 let binary = is_binary(&content);
1030 let hash = BlobStore::hash(&content);
1031 let size = u64::try_from(content.len()).unwrap_or(u64::MAX);
1032 let before = known.get(path).cloned();
1033 // Store content unless it's binary and the user opted out. The
1034 // stored hash/size come from the put *outcome*, not the disk
1035 // content: with a record-time redactor the stored (redacted)
1036 // content hashes differently from the on-disk original, and
1037 // events/diffs must reference the blob that actually exists.
1038 let (stored_hash, stored_size) = if binary && !opts.record_binary {
1039 (None, Some(size))
1040 } else {
1041 let outcome = blobs
1042 .put(&content)
1043 .map_err(|e| format!("blob put {}: {e}", path.display()))?;
1044 (Some(outcome.hash), Some(outcome.size))
1045 };
1046 // The before/after chain must point at fetchable blobs, so it
1047 // tracks the stored hash when content was stored, and falls back
1048 // to the disk-content hash for unstored (binary) files where the
1049 // hash is identity metadata only (FR-1.4).
1050 let effective_hash = stored_hash.clone().unwrap_or(hash);
1051 known.insert(path.to_path_buf(), effective_hash.clone());
1052 (
1053 before,
1054 Some(effective_hash),
1055 stored_hash,
1056 stored_size,
1057 binary,
1058 )
1059 };
1060
1061 let event = Event {
1062 session_id: session_id.to_string(),
1063 ts_ms,
1064 kind: EventKind::FileChange,
1065 step: None, // file changes are steps in replay (FR-3.4); step ordinal derived at read time.
1066 summary: truncate_summary(&format!("{rel_str} {change_kind}")),
1067 body_json: Some(serde_json::json!({
1068 "path": rel_str,
1069 "change_kind": change_kind.to_string(),
1070 })),
1071 blob_hash: blob_hash.clone(),
1072 blob_size,
1073 correlates: None,
1074 };
1075 let file_change = FileChange {
1076 event_id: 0, // overwritten by the writer (EventWriter::append_file_change).
1077 path: rel_str,
1078 change_kind,
1079 before_hash,
1080 after_hash,
1081 is_binary,
1082 };
1083
1084 // Recover from poisoning rather than failing this (and every subsequent)
1085 // capture: the writer `Mutex` is shared with the PTY reader and adapter
1086 // drain threads, and a panic in one of them must not silently blind the
1087 // others for the rest of the session (see `runner::lock_writer`).
1088 let writer = writer
1089 .lock()
1090 .unwrap_or_else(std::sync::PoisonError::into_inner);
1091 writer
1092 .append_file_change(event, file_change)
1093 .map_err(|e| format!("append file_change: {e}"))?;
1094 Ok(())
1095}
1096
1097/// Binary heuristic (FR-1.4): a NUL byte in the first 8 KiB.
1098fn is_binary(content: &[u8]) -> bool {
1099 content.iter().take(8192).any(|&b| b == 0)
1100}
1101
1102/// Truncate a summary to the SRS §4.1 limit of 120 chars.
1103fn truncate_summary(s: &str) -> String {
1104 const LIMIT: usize = 120;
1105 if s.chars().count() <= LIMIT {
1106 return s.to_string();
1107 }
1108 let truncated: String = s.chars().take(LIMIT - 1).collect();
1109 format!("{truncated}…")
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115
1116 #[test]
1117 fn nul_byte_is_binary() {
1118 assert!(is_binary(b"hello\x00world"));
1119 assert!(!is_binary(b"plain text only"));
1120 // NUL beyond 8 KiB is not detected (matches the heuristic).
1121 let mut big = vec![b'a'; 9000];
1122 big[9000 - 1] = 0;
1123 assert!(!is_binary(&big));
1124 }
1125
1126 #[test]
1127 fn truncate_summary_respects_limit() {
1128 let short = "a1b2c3";
1129 assert_eq!(truncate_summary(short), short);
1130 let long: String = "x".repeat(200);
1131 let t = truncate_summary(&long);
1132 assert!(t.chars().count() <= 120);
1133 assert!(t.ends_with('…'));
1134 }
1135
1136 #[test]
1137 fn build_matcher_skips_builtin() {
1138 let tmp = tempfile::tempdir().unwrap();
1139 let m = build_matcher(tmp.path(), &[]).unwrap();
1140 assert!(m
1141 .matched_path_or_any_parents("node_modules/foo.rs", false)
1142 .is_ignore());
1143 assert!(m
1144 .matched_path_or_any_parents(".git/HEAD", false)
1145 .is_ignore());
1146 assert!(m
1147 .matched_path_or_any_parents("target/debug/hh", false)
1148 .is_ignore());
1149 assert!(!m
1150 .matched_path_or_any_parents("src/main.rs", false)
1151 .is_ignore());
1152 }
1153
1154 #[test]
1155 fn build_matcher_extra_patterns() {
1156 let tmp = tempfile::tempdir().unwrap();
1157 let m = build_matcher(tmp.path(), &["dist/".into(), "*.lock".into()]).unwrap();
1158 assert!(m
1159 .matched_path_or_any_parents("dist/app.js", false)
1160 .is_ignore());
1161 assert!(m
1162 .matched_path_or_any_parents("Cargo.lock", false)
1163 .is_ignore());
1164 assert!(!m
1165 .matched_path_or_any_parents("src/main.rs", false)
1166 .is_ignore());
1167 }
1168
1169 #[test]
1170 fn merge_kind_collapse_editor_double_write() {
1171 // Two modifies of the same file within the window collapse to one Modify.
1172 let (k, s) = merge_kind(ChangeKind::Modified, ChangeKind::Modified);
1173 assert_eq!(k, ChangeKind::Modified);
1174 assert!(!s);
1175 // Create then Modify (editor's "write then touch") stays a single Created.
1176 let (k, s) = merge_kind(ChangeKind::Created, ChangeKind::Modified);
1177 assert_eq!(k, ChangeKind::Created);
1178 assert!(!s);
1179 }
1180
1181 #[test]
1182 fn merge_kind_create_then_delete_is_noop() {
1183 // Created then Removed within the window is a net no-op → suppress.
1184 let (k, s) = merge_kind(ChangeKind::Created, ChangeKind::Deleted);
1185 assert_eq!(k, ChangeKind::Deleted);
1186 assert!(s);
1187 }
1188
1189 #[test]
1190 fn merge_kind_modify_then_delete_is_delete() {
1191 let (k, s) = merge_kind(ChangeKind::Modified, ChangeKind::Deleted);
1192 assert_eq!(k, ChangeKind::Deleted);
1193 assert!(!s);
1194 }
1195
1196 #[test]
1197 fn merge_kind_delete_then_recreate_is_created() {
1198 let (k, s) = merge_kind(ChangeKind::Deleted, ChangeKind::Created);
1199 assert_eq!(k, ChangeKind::Created);
1200 assert!(!s);
1201 }
1202
1203 #[test]
1204 fn merge_kind_delete_then_stale_modify_stays_deleted() {
1205 // A Modify flag observed after a Delete in the same coalesced batch
1206 // is stale (the path can't be modified once gone) — ground truth
1207 // (deleted) must win, not the catch-all Modified fallback.
1208 let (k, s) = merge_kind(ChangeKind::Deleted, ChangeKind::Modified);
1209 assert_eq!(k, ChangeKind::Deleted);
1210 assert!(!s);
1211 }
1212
1213 #[test]
1214 fn coalesce_extends_window_and_suppresses_create_delete_burst() {
1215 let mut pending: HashMap<PathBuf, Pending> = HashMap::new();
1216 let path = PathBuf::from("/tmp/x");
1217 coalesce(&mut pending, path.clone(), ChangeKind::Created);
1218 let first_deadline = pending[&path].deadline;
1219 // A second event on the same path within the window extends the deadline.
1220 coalesce(&mut pending, path.clone(), ChangeKind::Deleted);
1221 let entry = &pending[&path];
1222 assert!(entry.deadline >= first_deadline);
1223 assert!(entry.suppress);
1224 }
1225
1226 #[test]
1227 fn coalesce_keeps_separate_paths_independent() {
1228 let mut pending: HashMap<PathBuf, Pending> = HashMap::new();
1229 coalesce(&mut pending, PathBuf::from("/tmp/a"), ChangeKind::Created);
1230 coalesce(&mut pending, PathBuf::from("/tmp/b"), ChangeKind::Modified);
1231 assert_eq!(pending.len(), 2);
1232 assert_eq!(pending[&PathBuf::from("/tmp/a")].kind, ChangeKind::Created);
1233 assert_eq!(pending[&PathBuf::from("/tmp/b")].kind, ChangeKind::Modified);
1234 }
1235
1236 #[test]
1237 fn resolve_first_seen_downgrades_created_for_known_existing_path() {
1238 // A path already in the baseline set was on disk before the watch
1239 // started: a raw `Created` for it is the macOS FSEvents quirk, not a
1240 // real creation. The baseline hash is preserved (not overwritten).
1241 let path = PathBuf::from("/tmp/modified.txt");
1242 let mut existing: HashMap<PathBuf, String> = [(path.clone(), "baseline-hash".into())]
1243 .into_iter()
1244 .collect();
1245 let kind = resolve_first_seen(ChangeKind::Created, &path, &mut existing);
1246 assert_eq!(kind, ChangeKind::Modified);
1247 assert_eq!(
1248 existing.get(&path).map(String::as_str),
1249 Some("baseline-hash")
1250 );
1251 }
1252
1253 #[test]
1254 fn resolve_first_seen_keeps_created_for_new_path_and_tracks_it() {
1255 let path = PathBuf::from("/tmp/created.txt");
1256 let mut existing: HashMap<PathBuf, String> = HashMap::new();
1257 let kind = resolve_first_seen(ChangeKind::Created, &path, &mut existing);
1258 assert_eq!(kind, ChangeKind::Created);
1259 // Now tracked, so a later edit is a genuine Modified, not re-downgraded.
1260 assert!(existing.contains_key(&path));
1261 }
1262
1263 #[test]
1264 fn resolve_first_seen_treats_recreate_after_delete_as_created() {
1265 // A baseline path that gets deleted then recreated within the same
1266 // session must report the recreate as a real Created, not Modified.
1267 let path = PathBuf::from("/tmp/doomed.txt");
1268 let mut existing: HashMap<PathBuf, String> = [(path.clone(), "baseline-hash".into())]
1269 .into_iter()
1270 .collect();
1271 assert_eq!(
1272 resolve_first_seen(ChangeKind::Deleted, &path, &mut existing),
1273 ChangeKind::Deleted
1274 );
1275 assert!(!existing.contains_key(&path));
1276 assert_eq!(
1277 resolve_first_seen(ChangeKind::Created, &path, &mut existing),
1278 ChangeKind::Created
1279 );
1280 }
1281
1282 #[test]
1283 fn scan_existing_files_finds_pre_existing_files_respecting_ignores() {
1284 let tmp = tempfile::tempdir().unwrap();
1285 let cwd = tmp.path();
1286 std::fs::write(cwd.join("modified.txt"), "orig\n").unwrap();
1287 std::fs::create_dir_all(cwd.join("sub")).unwrap();
1288 std::fs::write(cwd.join("sub").join("nested.txt"), "orig\n").unwrap();
1289 std::fs::create_dir_all(cwd.join("node_modules")).unwrap();
1290 std::fs::write(cwd.join("node_modules").join("ignored.txt"), "x\n").unwrap();
1291 let matcher = build_matcher(cwd, &[]).unwrap();
1292
1293 let mut existing = HashSet::new();
1294 scan_existing_files(cwd, cwd, &matcher, &[], &mut existing);
1295
1296 assert!(existing.contains(&cwd.join("modified.txt")));
1297 assert!(existing.contains(&cwd.join("sub").join("nested.txt")));
1298 assert!(!existing.contains(&cwd.join("node_modules").join("ignored.txt")));
1299 }
1300
1301 #[test]
1302 fn scan_existing_files_honors_internal_exclude() {
1303 let tmp = tempfile::tempdir().unwrap();
1304 let cwd = tmp.path();
1305 std::fs::create_dir_all(cwd.join(".hh")).unwrap();
1306 std::fs::write(cwd.join(".hh").join("hh.db"), "x\n").unwrap();
1307 let matcher = build_matcher(cwd, &[]).unwrap();
1308
1309 let mut existing = HashSet::new();
1310 scan_existing_files(cwd, cwd, &matcher, &[cwd.join(".hh")], &mut existing);
1311
1312 assert!(!existing.contains(&cwd.join(".hh").join("hh.db")));
1313 }
1314
1315 /// Panic hygiene (CLAUDE.md v1.0.0 addendum): a panic on the watcher
1316 /// thread must never abort the recording. This injects a real panic (via
1317 /// [`INJECT_PANIC_FOR_TEST`]) into a live `run_loop` — on a real thread,
1318 /// with the real `notify::Watcher` — and checks: (1) `stop_and_join`
1319 /// does not propagate it to the caller, (2) a write from *before* the
1320 /// panic stays durable, and (3) the shared writer — even if its `Mutex`
1321 /// was poisoned by the panic — still accepts further appends (the
1322 /// single-writer lock is shared with the PTY reader and adapter drain
1323 /// threads, so this is the difference between "one source degrades" and
1324 /// "the whole session silently stops recording").
1325 ///
1326 /// The panic trigger doesn't depend on a real OS file-change event: the
1327 /// injection check runs at the top of every `run_loop` iteration
1328 /// (including timeout-driven wakeups every `POLL_INTERVAL`), so arming
1329 /// the flag and waiting a few multiples of it is enough — deliberately
1330 /// avoiding a dependency on FSEvents actually firing, which was observed
1331 /// flaky/absent on GitHub's macOS runners. The process-global injection
1332 /// flag is why this test and [`watcher_captures_write_then_immediate_stop`]
1333 /// (the only other test that runs a real `run_loop`) take
1334 /// [`REAL_RUN_LOOP_MUTEX`]: without serialization a *concurrent* worker
1335 /// would observe the armed flag, panic before its own backstop runs, and
1336 /// turn that test into the macOS-CI `events seen: []` flake.
1337 #[test]
1338 fn watcher_panic_does_not_abort_recording() {
1339 use hh_core::event::{AdapterStatus, AgentKind, NewSession};
1340 use hh_core::store::Store;
1341
1342 // Serialize with the other real-`run_loop` test: this test arms the
1343 // process-global `INJECT_PANIC_FOR_TEST` flag, which a concurrently-
1344 // running worker from `watcher_captures_write_then_immediate_stop`
1345 // would observe and panic on, aborting before its backstop runs. Hold
1346 // the guard for the whole test so the flag is only seen by OUR worker.
1347 let _real_run_guard = REAL_RUN_LOOP_MUTEX
1348 .lock()
1349 .unwrap_or_else(std::sync::PoisonError::into_inner);
1350
1351 let tmp = tempfile::tempdir().unwrap();
1352 let cwd = tmp.path().join("work");
1353 std::fs::create_dir_all(&cwd).unwrap();
1354 let store = Store::open(&tmp.path().join("hh.db"), &tmp.path().join("blobs")).unwrap();
1355 let created = store
1356 .create_session(&NewSession {
1357 id: hh_core::event::now_v7(),
1358 started_at: 0,
1359 agent_kind: AgentKind::Generic,
1360 adapter_status: AdapterStatus::None,
1361 command: vec!["test".into()],
1362 cwd: cwd.clone(),
1363 hostname: None,
1364 hh_version: "test".into(),
1365 model: None,
1366 git_branch: None,
1367 git_sha: None,
1368 git_dirty: None,
1369 })
1370 .unwrap();
1371 let writer = Arc::new(Mutex::new(store.event_writer().unwrap()));
1372 let blobs = Arc::new(BlobStore::new(store.blobs().root().to_path_buf()));
1373
1374 // A write from before anything panics, appended directly (not via a
1375 // real FS event — see the doc comment above) so durability is
1376 // asserted deterministically.
1377 writer
1378 .lock()
1379 .unwrap_or_else(std::sync::PoisonError::into_inner)
1380 .append_event(Event {
1381 session_id: created.id.clone(),
1382 ts_ms: 0,
1383 kind: EventKind::Lifecycle,
1384 step: None,
1385 summary: "before panic".into(),
1386 body_json: None,
1387 blob_hash: None,
1388 blob_size: None,
1389 correlates: None,
1390 })
1391 .unwrap();
1392
1393 let opts = WatchOptions {
1394 cwd: cwd.clone(),
1395 max_file_size: 4 * 1024 * 1024,
1396 record_binary: false,
1397 extra_ignore: Vec::new(),
1398 internal_exclude: Vec::new(),
1399 };
1400 let handle = spawn_watcher(
1401 opts,
1402 Arc::clone(&writer),
1403 Arc::clone(&blobs),
1404 created.id.clone(),
1405 Instant::now(),
1406 )
1407 .unwrap()
1408 .expect("watcher should initialize on a writable temp dir");
1409
1410 // Arm the injection; the loop wakes up (and hits the check) every
1411 // POLL_INTERVAL regardless of any real filesystem event, so a few
1412 // multiples of it is a generous, platform-independent margin.
1413 INJECT_PANIC_FOR_TEST.store(true, Ordering::SeqCst);
1414 std::thread::sleep(POLL_INTERVAL * 20);
1415
1416 // Must not propagate the panic to this thread.
1417 handle.stop_and_join();
1418
1419 let index = store.list_event_index(&created.id).unwrap();
1420 assert!(
1421 index.iter().any(|e| e.summary == "before panic"),
1422 "the write from before the injected panic must still be durable"
1423 );
1424
1425 let w = writer
1426 .lock()
1427 .unwrap_or_else(std::sync::PoisonError::into_inner);
1428 w.append_event(Event {
1429 session_id: created.id.clone(),
1430 ts_ms: 1,
1431 kind: EventKind::Lifecycle,
1432 step: None,
1433 summary: "after panic".into(),
1434 body_json: None,
1435 blob_hash: None,
1436 blob_size: None,
1437 correlates: None,
1438 })
1439 .expect("the writer must still accept appends after the watcher thread panicked");
1440 }
1441
1442 /// Shutdown capture (FR-1.4 robustness on macOS FSEvents): a write
1443 /// performed immediately before `stop_and_join` must still be recorded.
1444 /// On macOS, FSEvents can deliver a write event AFTER the process that
1445 /// performed it has exited — or never deliver it at all on GitHub's macOS
1446 /// runners. The old behavior (stop the worker the instant `stop` is set,
1447 /// then flush only what was already pending) dropped that event, so a
1448 /// quick-exiting agent finalized with "0 file changes". Two mechanisms now
1449 /// cover it: the grace drain in [`run_loop`] catches *late* events, and the
1450 /// [`rescan_for_missed_changes`] backstop catches the *absent*-event case
1451 /// deterministically (it does not depend on `notify` delivering anything).
1452 ///
1453 /// This test is deterministic, not a flaky race, because the startup
1454 /// baseline is captured synchronously in [`spawn_watcher`] before it
1455 /// returns: `race.txt` is written AFTER `spawn_watcher` returns, so it is
1456 /// guaranteed post-baseline and the backstop records it as a `Created`
1457 /// even when `notify` delivers nothing. (Earlier, when the baseline scan
1458 /// ran on the worker thread, macOS scheduling could run it AFTER the write
1459 /// and fold `race.txt` into the baseline — the backstop then saw it as
1460 /// "unchanged" and skipped it, producing the `events seen: []` flake.)
1461 #[test]
1462 fn watcher_captures_write_then_immediate_stop() {
1463 use hh_core::event::{AdapterStatus, AgentKind, EventKind, NewSession};
1464 use hh_core::store::Store;
1465
1466 // Serialize with `watcher_panic_does_not_abort_recording`: that test
1467 // arms the process-global `INJECT_PANIC_FOR_TEST` flag, which OUR worker
1468 // would observe if it ran concurrently and panic — aborting before the
1469 // backstop runs and turning this into the macOS-CI `events seen: []`
1470 // flake. Hold the guard so the flag is only seen by that test's worker.
1471 let _real_run_guard = REAL_RUN_LOOP_MUTEX
1472 .lock()
1473 .unwrap_or_else(std::sync::PoisonError::into_inner);
1474
1475 let tmp = tempfile::tempdir().unwrap();
1476 let cwd = tmp.path().join("work");
1477 std::fs::create_dir_all(&cwd).unwrap();
1478 let store = Store::open(&tmp.path().join("hh.db"), &tmp.path().join("blobs")).unwrap();
1479 let created = store
1480 .create_session(&NewSession {
1481 id: hh_core::event::now_v7(),
1482 started_at: 0,
1483 agent_kind: AgentKind::Generic,
1484 adapter_status: AdapterStatus::None,
1485 command: vec!["test".into()],
1486 cwd: cwd.clone(),
1487 hostname: None,
1488 hh_version: "test".into(),
1489 model: None,
1490 git_branch: None,
1491 git_sha: None,
1492 git_dirty: None,
1493 })
1494 .unwrap();
1495 let writer = Arc::new(Mutex::new(store.event_writer().unwrap()));
1496 let blobs = Arc::new(BlobStore::new(store.blobs().root().to_path_buf()));
1497
1498 let opts = WatchOptions {
1499 cwd: cwd.clone(),
1500 max_file_size: 4 * 1024 * 1024,
1501 record_binary: false,
1502 extra_ignore: Vec::new(),
1503 internal_exclude: Vec::new(),
1504 };
1505 let handle = spawn_watcher(
1506 opts,
1507 Arc::clone(&writer),
1508 Arc::clone(&blobs),
1509 created.id.clone(),
1510 Instant::now(),
1511 )
1512 .unwrap()
1513 .expect("watcher should initialize on a writable temp dir");
1514
1515 // Write a file and stop the watcher immediately — the FSEvents event
1516 // for this write may not have been delivered (or never will be).
1517 std::fs::write(cwd.join("race.txt"), b"written just before stop\n").unwrap();
1518 handle.stop_and_join();
1519
1520 let index = store.list_event_index(&created.id).unwrap();
1521 assert!(
1522 index
1523 .iter()
1524 .any(|e| { e.kind == EventKind::FileChange && e.summary.contains("race.txt") }),
1525 "a write immediately before stop must be captured (grace drain + \
1526 rescan backstop); events seen: {:?}",
1527 index
1528 .iter()
1529 .map(|e| (e.kind, e.summary.clone()))
1530 .collect::<Vec<_>>()
1531 );
1532 }
1533
1534 /// Deterministic unit test for [`rescan_for_missed_changes`] — the backstop
1535 /// that catches file changes the watcher never observed. Unlike
1536 /// [`watcher_captures_write_then_immediate_stop`] this does NOT spawn a
1537 /// real `notify` watcher, so it is fully deterministic and not subject to
1538 /// macOS FSEvents flakiness: it calls the backstop directly with a
1539 /// hand-built `existing` baseline (path → startup hash) and `known` set.
1540 /// Asserts:
1541 /// - an unobserved new file is recorded as `Created`;
1542 /// - a pre-existing file whose current hash differs from its baseline is
1543 /// recorded as `Modified` (the missed-modify case the hash baseline
1544 /// enables);
1545 /// - an unchanged baseline file and an already-recorded file are NOT
1546 /// re-recorded (no duplicates).
1547 #[test]
1548 fn rescan_for_missed_changes_records_unobserved_creates_and_modifies() {
1549 use hh_core::event::{AdapterStatus, AgentKind, EventKind, NewSession};
1550 use hh_core::store::Store;
1551
1552 let tmp = tempfile::tempdir().unwrap();
1553 let cwd = tmp.path().join("work");
1554 std::fs::create_dir_all(&cwd).unwrap();
1555 let store = Store::open(&tmp.path().join("hh.db"), &tmp.path().join("blobs")).unwrap();
1556 let created = store
1557 .create_session(&NewSession {
1558 id: hh_core::event::now_v7(),
1559 started_at: 0,
1560 agent_kind: AgentKind::Generic,
1561 adapter_status: AdapterStatus::None,
1562 command: vec!["test".into()],
1563 cwd: cwd.clone(),
1564 hostname: None,
1565 hh_version: "test".into(),
1566 model: None,
1567 git_branch: None,
1568 git_sha: None,
1569 git_dirty: None,
1570 })
1571 .unwrap();
1572 let writer = Arc::new(Mutex::new(store.event_writer().unwrap()));
1573 let blobs = Arc::new(BlobStore::new(store.blobs().root().to_path_buf()));
1574
1575 // baseline.txt: existed before the watch, unchanged since. Its baseline
1576 // hash equals its current content hash → must NOT be recorded.
1577 let baseline_bytes = b"already here\n";
1578 std::fs::write(cwd.join("baseline.txt"), baseline_bytes).unwrap();
1579 // changed.txt: existed before the watch (baseline hash of "original\n")
1580 // but was modified during the session to "modified\n" with no event
1581 // observed → must be recorded as `Modified`.
1582 std::fs::write(cwd.join("changed.txt"), b"modified\n").unwrap();
1583 // recorded.txt: the watcher already captured it → in `known`. Must NOT
1584 // be re-recorded.
1585 std::fs::write(cwd.join("recorded.txt"), b"captured via event\n").unwrap();
1586 // missed.txt: created during the session but the watcher never saw an
1587 // event for it → not in `existing`, not in `known`. MUST be recorded as
1588 // `Created`.
1589 std::fs::write(cwd.join("missed.txt"), b"the watcher missed this\n").unwrap();
1590
1591 // `existing` is the startup baseline: path → content hash. For
1592 // baseline.txt that is the hash of its (unchanged) content; for
1593 // changed.txt it is the hash of the ORIGINAL content ("original\n"),
1594 // which differs from what is on disk now.
1595 let existing: HashMap<PathBuf, String> = [
1596 (cwd.join("baseline.txt"), BlobStore::hash(baseline_bytes)),
1597 (cwd.join("changed.txt"), BlobStore::hash(b"original\n")),
1598 ]
1599 .into_iter()
1600 .collect();
1601 // `known` holds path → content hash; the hash value is irrelevant for
1602 // the backstop's skip check (it only tests key presence), so a dummy
1603 // hash is fine.
1604 let mut known: HashMap<PathBuf, String> = [(cwd.join("recorded.txt"), "dummyhash".into())]
1605 .into_iter()
1606 .collect();
1607
1608 let opts = WatchOptions {
1609 cwd: cwd.clone(),
1610 max_file_size: 4 * 1024 * 1024,
1611 record_binary: false,
1612 extra_ignore: Vec::new(),
1613 internal_exclude: Vec::new(),
1614 };
1615 let matcher = build_matcher(&cwd, &[]).unwrap();
1616 rescan_for_missed_changes(
1617 &opts,
1618 &matcher,
1619 &writer,
1620 &blobs,
1621 &created.id,
1622 Instant::now(),
1623 &mut known,
1624 &existing,
1625 );
1626
1627 let index = store.list_event_index(&created.id).unwrap();
1628 let summaries: Vec<String> = index.iter().map(|e| e.summary.clone()).collect();
1629 assert!(
1630 index
1631 .iter()
1632 .any(|e| e.kind == EventKind::FileChange && e.summary.contains("missed.txt")),
1633 "the unobserved new file must be recorded by the backstop; events: {summaries:?}"
1634 );
1635 assert!(
1636 index.iter().any(|e| {
1637 e.kind == EventKind::FileChange
1638 && e.summary.contains("changed.txt")
1639 && e.summary.contains("modified")
1640 }),
1641 "a baseline file whose current hash differs must be recorded as Modified; events: {summaries:?}"
1642 );
1643 assert!(
1644 !summaries.iter().any(|s| s.contains("baseline.txt")),
1645 "an unchanged baseline file must not be recorded; events: {summaries:?}"
1646 );
1647 assert!(
1648 !summaries.iter().any(|s| s.contains("recorded.txt")),
1649 "an already-recorded file must not be duplicated; events: {summaries:?}"
1650 );
1651 }
1652
1653 /// FR-1.5 best-effort: when the cwd itself cannot be watched (here: it does
1654 /// not exist), `spawn_watcher` must return `Ok(None)` — degrading
1655 /// file-change recording with a warning — rather than `Err`, which would
1656 /// abort the whole recording. The recorder continues with PTY + adapter
1657 /// capture. (The accompanying stderr warning is asserted end-to-end in the
1658 /// `hh/tests` integration suite.)
1659 #[test]
1660 fn spawn_watcher_degrades_when_cwd_unwatchable() {
1661 use hh_core::store::Store;
1662 let tmp = tempfile::tempdir().unwrap();
1663 let store = Store::open(&tmp.path().join("hh.db"), &tmp.path().join("blobs")).unwrap();
1664 let writer = Arc::new(Mutex::new(store.event_writer().unwrap()));
1665 let blobs = Arc::new(BlobStore::new(store.blobs().root().to_path_buf()));
1666 let opts = WatchOptions {
1667 // A path that does not exist and cannot be watched.
1668 cwd: tmp.path().join("does-not-exist"),
1669 max_file_size: 4 * 1024 * 1024,
1670 record_binary: false,
1671 extra_ignore: Vec::new(),
1672 internal_exclude: Vec::new(),
1673 };
1674 let outcome = spawn_watcher(opts, writer, blobs, "session".into(), Instant::now());
1675 assert!(
1676 outcome.is_ok(),
1677 "watcher init failure must not abort the recording"
1678 );
1679 assert!(
1680 outcome.unwrap().is_none(),
1681 "an unwatchable cwd must degrade to Ok(None), not a handle"
1682 );
1683 }
1684
1685 /// The per-directory fallback walk skips ignored directories (built-in
1686 /// `.git`/`node_modules`/`target`) so it does not waste watches on — or
1687 /// descend into — the very subtrees the matcher would filter anyway. This
1688 /// exercises `add_per_dir_watches` directly against a real `notify` watcher.
1689 #[test]
1690 fn add_per_dir_watches_skips_ignored_dirs() {
1691 let tmp = tempfile::tempdir().unwrap();
1692 let cwd = tmp.path();
1693 std::fs::create_dir_all(cwd.join("keep")).unwrap();
1694 std::fs::create_dir_all(cwd.join("node_modules").join("deep")).unwrap();
1695 std::fs::create_dir_all(cwd.join("target")).unwrap();
1696 let matcher = build_matcher(cwd, &[]).unwrap();
1697 let (ntx, _nrx) = std::sync::mpsc::channel();
1698 let mut watcher = notify::recommended_watcher(
1699 move |r: std::result::Result<notify::Event, notify::Error>| {
1700 let _ = ntx.send(r);
1701 },
1702 )
1703 .expect("notify watcher init");
1704 // Must report the cwd as watched (at least one dir) and not error.
1705 assert!(add_per_dir_watches(&mut watcher, cwd, &matcher, &[]));
1706 }
1707}