Skip to main content

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: &notify::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}