//! Filesystem notifications with debouncing and gitignore support. //! //! ## Ignoring Files //! //! Events for ignored files (`.gitignore` and custom patterns) are filtered out //! before being sent to consumers. //! //! ## Watch Strategies //! //! Two OS-watch layouts, chosen per platform (see [`WatchStrategy`]): //! //! - **Fan-out** (macOS / Windows): root non-recursive + one *recursive* watch //! per non-ignored top-level child (capped, else recursive root). Recursion //! there is kernel-side (FSEvents / ReadDirectoryChangesW) — one handle per //! watch regardless of tree size — so skipping only *top-level* ignored dirs //! is enough. //! - **Per-dir** (Linux): inotify has no kernel recursion; notify emulates it //! by walking the tree and adding **one watch descriptor per directory**, //! including gitignored trees (`node_modules/`, `target/`, `.venv/`) nested //! below the top level. That exhausts `fs.inotify.max_user_watches` for //! every process on the box. Instead we walk with the `ignore` crate //! (gitignore-aware at every depth), add a *non-recursive* watch per //! surviving dir — shallow-first, bounded by [`max_watch_budget`] — and //! maintain the set incrementally: new dirs are watched parent-before-listing //! with synthetic `Created` backfill for files that raced the watch, deleted //! dirs are pruned by prefix. `.git` is watched surgically (non-recursive //! `.git` + `refs`, recursive `refs/heads` + `refs/tags`) instead of //! recursively, so `objects/` and `modules/` (13k+ dirs on big repos) cost //! nothing. `KIGI_FSNOTIFY_PER_DIR=1|0` overrides the platform default. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime}; use globset::{Glob, GlobSet, GlobSetBuilder}; use ignore::WalkBuilder; use ignore::gitignore::{Gitignore, GitignoreBuilder}; use notify::RecursiveMode; use notify::event::EventKind; use notify_debouncer_full::{ DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt, }; use tokio::sync::mpsc; const DEBOUNCE_MS: u64 = 100; use crate::event::FsEventKind; /// Raw OS-level event from the debouncer. Internal; the semantic public /// `FsEvent` enum lives in `event.rs`. #[derive(Debug, Clone)] pub(crate) struct RawFsEvent { pub paths: Vec, pub kind: FsEventKind, } /// Map a notify `EventKind` to our public `FsEventKind`. Returns `None` /// for events we don't surface (Access, Any, Other) — filtered before /// reaching consumers so the public enum has no unobservable variants. fn map_event_kind(kind: &EventKind) -> Option { use notify::event::ModifyKind; match kind { EventKind::Create(_) => Some(FsEventKind::Created), EventKind::Modify(ModifyKind::Name(_)) => Some(FsEventKind::Renamed), EventKind::Modify(_) => Some(FsEventKind::Modified), EventKind::Remove(_) => Some(FsEventKind::Removed), EventKind::Access(_) | EventKind::Any | EventKind::Other => None, } } /// Internal raw OS-watcher config. The user-facing version is `crate::source::FsConfig`. /// `.git/` is always allowed through; the source classifies internally. #[derive(Debug, Clone)] pub(crate) struct FsNotifyConfig { pub debounce_ms: u64, pub ignore_patterns: Vec, } impl Default for FsNotifyConfig { fn default() -> Self { Self { debounce_ms: DEBOUNCE_MS, ignore_patterns: vec![], } } } /// Permissive on purpose: lets `.git/index.lock` and `.git/gc.pid` through /// to drive the lock state machine. `crate::paths::classify_git_path` keeps /// them out of `GitMetaChanged`. Do not unify. fn is_git_path_for_watcher(path: &Path) -> bool { let s = path.to_string_lossy(); s.contains(".git/index") || s.contains(".git/HEAD") || s.contains(".git/FETCH_HEAD") || s.contains(".git/refs/") || s.contains(".git/packed-refs") || s.contains(".git/gc.pid") } /// Sapling analogue of [`is_git_path_for_watcher`]: lets **only** `.sl/wlock` /// through. `.sl/dirstate` is deliberately not watched — it is read on demand, /// because a read-only `sl status` rewrites dirstate without moving the parent, /// so watching it would turn every status into a refresh storm. Forward-slash /// only, like its git sibling. fn is_sl_path_for_watcher(path: &Path) -> bool { path.to_string_lossy().contains(".sl/wlock") } /// True if `p`'s final component is exactly `name` (`.git`/`.sl`). Uses /// `file_name` rather than `Path::ends_with` to dodge clippy's /// `path_ends_with_ext` false positive on `.sl`. fn dir_named(p: &Path, name: &str) -> bool { p.file_name().is_some_and(|n| n == name) } /// Whether Sapling (`.sl`) support is enabled (default on; `KIGI_FSNOTIFY_SAPLING=0` /// or `false` disables it). Resolved once per watcher in `FsEventSource::start_on` /// and threaded down, so discovery, watching, and filtering can't disagree. pub(crate) fn sapling_enabled() -> bool { !matches!( std::env::var("KIGI_FSNOTIFY_SAPLING").ok().as_deref(), Some("0") | Some("false") ) } /// How OS watches are laid out over the workspace. See the module docs for /// the platform rationale. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum WatchStrategy { /// Root non-recursive + recursive top-level children (or recursive root /// past [`MAX_TOP_LEVEL_FANOUT`]). Kernel-recursive backends. Fanout, /// One non-recursive watch per non-ignored directory, full depth. /// Emulated-recursion backends (inotify), where this is strictly cheaper. PerDir, } /// Resolve the strategy: `KIGI_FSNOTIFY_PER_DIR=1|true` forces per-dir, /// `=0|false` forces fan-out, otherwise per-dir on Linux (inotify) and /// fan-out elsewhere. Resolved once in [`start_with_timeout`] like the /// Sapling switch, so selection and maintenance can't disagree. pub(crate) fn watch_strategy() -> WatchStrategy { match std::env::var("KIGI_FSNOTIFY_PER_DIR").ok().as_deref() { Some("1") | Some("true") => WatchStrategy::PerDir, Some("0") | Some("false") => WatchStrategy::Fanout, _ if cfg!(target_os = "linux") => WatchStrategy::PerDir, _ => WatchStrategy::Fanout, } } /// Per-dir mode's total watch budget (`KIGI_FSNOTIFY_MAX_WATCHES` overrides). /// /// Watches are added shallow-first, so hitting the budget sheds the *deepest* /// directories; a warning is logged once. The default stays within a typical /// `fs.inotify.max_user_watches` (65,536 on many distros) while leaving room /// for other processes — the entire point of this mode is not to starve them. const DEFAULT_MAX_WATCHES: usize = 49_152; pub(crate) fn max_watch_budget() -> usize { std::env::var("KIGI_FSNOTIFY_MAX_WATCHES") .ok() .and_then(|v| v.parse::().ok()) .filter(|&n| n > 0) .unwrap_or(DEFAULT_MAX_WATCHES) } #[derive(Default)] struct GitignoreCache { cache: HashMap, } impl GitignoreCache { /// Check if a path should be ignored. /// /// With `watch_vcs`, the metadata files that drive the lock state machine /// pass through: git's (`.git/index`, `.git/HEAD`, …) and, when `sapling`, /// `.sl/wlock`. Everything else under `.git`/`.sl` stays ignored. fn is_ignored(&mut self, path: &Path, watch_vcs: bool, sapling: bool) -> bool { let is_dir = path.is_dir(); let mut current_dir = path.parent(); while let Some(dir) = current_dir { if dir_named(dir, ".git") { if watch_vcs && is_git_path_for_watcher(path) { return false; } return true; } if sapling && dir_named(dir, ".sl") { if watch_vcs && is_sl_path_for_watcher(path) { return false; } return true; } let gitignore_path = dir.join(".gitignore"); if let Ok(metadata) = gitignore_path.metadata() && let Ok(mtime) = metadata.modified() { let gitignore = self.get_or_load(&gitignore_path, dir, mtime); let m = gitignore.matched_path_or_any_parents(path, is_dir); if m.is_ignore() { return true; } if m.is_whitelist() { // A negation rule in this (deeper) .gitignore explicitly // un-ignores the path. Shallower .gitignore files must not override. return false; } } current_dir = dir.parent(); } false } fn get_or_load(&mut self, gitignore_path: &Path, root: &Path, mtime: SystemTime) -> &Gitignore { let key = gitignore_path.to_path_buf(); if let Some((cached_mtime, _)) = self.cache.get(&key) && *cached_mtime == mtime { return &self.cache[&key].1; } let mut builder = GitignoreBuilder::new(root); let _ = builder.add(gitignore_path); let gitignore = builder.build().unwrap_or_else(|_| Gitignore::empty()); self.cache.insert(key.clone(), (mtime, gitignore)); &self.cache[&key].1 } } fn merge_events(events: impl IntoIterator) -> Vec { let mut by_path: HashMap = HashMap::new(); // Rename events preserve original path ordering from the OS ([old, new]), // so they bypass the HashMap merge and are emitted directly. let mut rename_events: Vec = Vec::new(); for event in events.into_iter() { let Some(kind) = map_event_kind(&event.kind) else { continue; }; if kind == FsEventKind::Renamed { rename_events.push(RawFsEvent { paths: event.event.paths, kind: FsEventKind::Renamed, }); continue; } let paths = event.event.paths; for path in paths { by_path .entry(path) .and_modify(|existing| match (*existing, kind) { (_, FsEventKind::Removed) => *existing = FsEventKind::Removed, (FsEventKind::Created, FsEventKind::Modified) => {} (FsEventKind::Modified, FsEventKind::Created) => { *existing = FsEventKind::Created } _ => {} }) .or_insert(kind); } } let mut result: HashMap> = HashMap::new(); for (path, kind) in by_path { result.entry(kind).or_default().push(path); } let mut merged: Vec = result .into_iter() .map(|(kind, paths)| RawFsEvent { paths, kind }) .collect(); merged.extend(rename_events); merged } /// Work forwarded from the debouncer callback (on notify's thread) to the /// watcher thread that owns the debouncer. enum WatchCommand { /// Re-evaluate the recursive child watches after a top-level structural change. /// Fan-out mode only. Reconcile, /// Per-dir mode: apply a structural delta. `pruned` (removed/renamed-away /// roots) is processed **before** `added` (created/renamed-in dirs) so a /// rename never `unwatch`es the watch descriptor its new path just re-bound /// (inotify wds follow inodes). Update { pruned: Vec, added: Vec, }, /// Stop the watcher thread. Shutdown, } pub(crate) struct FsNotifyHandle { cmd_tx: Option>, thread: Option>, /// Live OS-watch count (workspace + VCS), for stats/benchmarks. watch_count: Arc, } impl FsNotifyHandle { /// Number of OS-level watches currently held (per-dir mode counts one per /// directory; fan-out mode one per `watch()` call). pub(crate) fn watch_count(&self) -> usize { self.watch_count.load(Ordering::Relaxed) } } impl Drop for FsNotifyHandle { fn drop(&mut self) { tracing::debug!("fs_notify: stopping watcher thread"); // Explicit signal: the callback holds another sender, so dropping ours // wouldn't disconnect the thread's `recv`. if let Some(tx) = self.cmd_tx.take() { let _ = tx.send(WatchCommand::Shutdown); } if let Some(handle) = self.thread.take() { let _ = handle.join(); } } } fn build_globsets(patterns: &[String]) -> (Option, Option) { let mut ignore_builder = GlobSetBuilder::new(); let mut include_builder = GlobSetBuilder::new(); for pattern in patterns { let (is_negation, raw_pattern) = pattern .strip_prefix('!') .map_or((false, pattern.as_str()), |p| (true, p)); // Make patterns match anywhere in the path with **/ prefix if needed let glob_pattern = if raw_pattern.starts_with("**/") || raw_pattern.starts_with('/') { raw_pattern.to_string() } else { format!("**/{raw_pattern}") }; if let Ok(glob) = Glob::new(&glob_pattern) { if is_negation { include_builder.add(glob); } else { ignore_builder.add(glob); } } else { tracing::warn!("invalid pattern: {}", pattern); } } let ignore_set = ignore_builder.build().ok().filter(|s| !s.is_empty()); let include_set = include_builder.build().ok().filter(|s| !s.is_empty()); (ignore_set, include_set) } /// Default timeout for watcher initialization. const WATCHER_INIT_TIMEOUT_SECS: u64 = 10; /// Fan out (watch each non-ignored top-level child recursively) only up to this /// many children; above it, use one recursive root watch. /// /// Gated on width, not on whether anything is ignored now: reconcile re-evaluates /// the set on structural changes, so a `target/` created after start is still /// excluded — a "fan out only if something is ignored" gate would miss it. The /// cap bounds fan-out's one-`watch()`-per-child cost for wide trees with nothing /// to skip, where a single recursive watch is cheaper. const MAX_TOP_LEVEL_FANOUT: usize = 64; #[derive(Debug, Clone)] struct StartProgress { started_at: Instant, stage: &'static str, stage_started_at: Instant, timeline: Vec<(&'static str, Duration)>, } impl StartProgress { fn new() -> Self { let now = Instant::now(); Self { started_at: now, stage: "init", stage_started_at: now, timeline: vec![("init", Duration::from_millis(0))], } } fn set_stage(&mut self, stage: &'static str) { let t = self.started_at.elapsed(); self.stage = stage; self.stage_started_at = Instant::now(); // Keep this bounded to avoid unbounded growth. if self.timeline.len() < 32 { self.timeline.push((stage, t)); } } fn snapshot( &self, ) -> ( &'static str, Duration, Duration, Vec<(&'static str, Duration)>, ) { ( self.stage, self.stage_started_at.elapsed(), self.started_at.elapsed(), self.timeline.clone(), ) } } /// True if `path` is an immediate child of `root`. fn is_top_level_child(path: &Path, root: &Path) -> bool { path.parent() == Some(root) } /// True if an event is a structural change (create/remove/rename) to a direct /// child of `root` — the only kind that can add or remove a top-level watch. fn event_triggers_reconcile(kind: FsEventKind, paths: &[PathBuf], root: &Path) -> bool { matches!( kind, FsEventKind::Created | FsEventKind::Removed | FsEventKind::Renamed ) && paths.iter().any(|p| is_top_level_child(p, root)) } /// Per-dir mode: classify one event's paths into watch-set delta *candidates*. /// /// Classified primarily by **on-disk state**, because backends report /// structure ambiguously: FSEvents can coalesce a subtree removal into /// `Modified` on the (now-vanished) parent, [`merge_events`] folds a /// remove+recreate into `Removed`, and renames arrive as `From`/`To`/`Both` /// shapes under `NoCache`. A path that is a directory right now (lstat; /// symlinks excluded) is an add candidate; anything else (missing, file, /// symlink) is a prune candidate. /// /// The event *kind* contributes one thing state can't: a **structural** event /// (create/remove/rename) on a still-existing dir may be a delete+recreate /// inside one debounce window — the old inode's inotify watch is already dead /// even though the path looks watched — so the dir is *also* pruned, forcing /// an unwatch/re-watch re-arm (prunes are processed before adds). /// /// Both lists are candidates only: the watcher thread rejects already-watched /// adds and never-watched prunes in O(1), so the common case (file events, /// dir-metadata touches) costs one `HashSet` lookup. fn scan_per_dir_updates( kind: FsEventKind, paths: &[PathBuf], pruned: &mut Vec, added: &mut Vec, ) { let structural = matches!( kind, FsEventKind::Created | FsEventKind::Removed | FsEventKind::Renamed ); for p in paths { let is_dir = p.symlink_metadata().is_ok_and(|m| m.file_type().is_dir()); if is_dir { if structural { // Re-arm a possibly-dead watch. pruned.push(p.clone()); } added.push(p.clone()); } else { pruned.push(p.clone()); } } } /// Locate the `.git` directory governing `watch_path` (searching its ancestors). /// /// A real (non-symlink) `.git` directory is returned directly via a cheap /// `symlink_metadata` check (no link-follow), and lives inside the canonical /// ancestor so it can't escape. A `.git` file or symlink is resolved through /// `git2`, which rejects a pointer to a non-git target (e.g. a planted /// `gitdir: ~/.ssh` or `ln -s ~/.ssh .git`) instead of watching it. fn find_git_dir(watch_path: &Path) -> Option { for ancestor in watch_path.ancestors() { let dot_git = ancestor.join(".git"); let Ok(meta) = dot_git.symlink_metadata() else { continue; }; if meta.file_type().is_dir() { return Some(dunce::canonicalize(&dot_git).unwrap_or(dot_git)); } // A `.git` file or symlink: let git validate the target before watching. if let Ok(repo) = git2::Repository::open(ancestor) { let gd = repo.path().to_path_buf(); return Some(dunce::canonicalize(&gd).unwrap_or(gd)); } } None } /// Locate the `.sl` working-copy directory governing `watch_path` (ancestor /// walk), mirroring [`find_git_dir`]'s real-directory branch: a non-symlink /// `.sl` dir via `symlink_metadata` (no link-follow), canonicalized so it can't /// escape. Sapling has no `.sl`-file indirection. pub(crate) fn find_sl_dir(watch_path: &Path) -> Option { for ancestor in watch_path.ancestors() { let dot_sl = ancestor.join(".sl"); let Ok(meta) = dot_sl.symlink_metadata() else { continue; }; if meta.file_type().is_dir() { return Some(dunce::canonicalize(&dot_sl).unwrap_or(dot_sl)); } } None } /// Whether a discovered VCS metadata dir (`.git`/`.sl`) needs its own watch: /// always in fan-out mode (the root is non-recursive); under a recursive root /// only for an *external* (ancestor) dir — an internal one is already covered, /// so re-watching it would be a redundant double-watch. fn should_watch_separate_vcs_dir(fanout: bool, vcs_dir: &Path, watch_path: &Path) -> bool { fanout || !vcs_dir.starts_with(watch_path) } /// Apply the custom ignore/include globsets (a negation `include` wins). `None` /// for both leaves the path unfiltered. fn passes_custom_globs( path: &Path, custom_ignore: &Option, custom_include: &Option, ) -> bool { if let Some(include_set) = custom_include.as_ref() && include_set.is_match(path) { return true; } if let Some(ignore_set) = custom_ignore.as_ref() && ignore_set.is_match(path) { return false; } true } /// `WalkBuilder` configured for watch selection: honors `.gitignore`, /// `.git/info/exclude`, global excludes, and `.ignore`, and never follows /// symlinks (so watches can't leave the workspace via a symlinked dir). fn ignore_walker(root: &Path, max_depth: Option) -> ignore::Walk { WalkBuilder::new(root) // Let gitignore, not the leading dot, decide. .hidden(false) .git_ignore(true) .git_global(true) .git_exclude(true) .ignore(true) .follow_links(false) .max_depth(max_depth) .build() } /// Immediate child directories of `root` to watch recursively. /// /// The root is watched non-recursively (see [`start_with_timeout`]); each child /// directory surviving gitignore + custom patterns is watched recursively, so a /// gitignored top-level tree (e.g. `target/`) is never watched. `.git`/`.sl` /// are excluded (watched separately); files are covered by the root watch; /// symlinked children are skipped. A `custom_include` negation overrides /// `custom_ignore` but not `.gitignore` (`WalkBuilder` never yields a gitignored /// child). fn select_top_level_watch_dirs( root: &Path, custom_ignore: &Option, custom_include: &Option, ) -> Vec { // `usize::MAX` cap → never exceeded → always `Some`. select_top_level_watch_dirs_capped(root, custom_ignore, custom_include, usize::MAX) .unwrap_or_default() } /// Like [`select_top_level_watch_dirs`] but returns `None` once the non-ignored /// count exceeds `max`, stopping the walk early. /// /// The fan-out vs. recursive-root decision uses this non-ignored count /// (gitignored children and `.git`/`.sl` don't count), and on `Some` the /// returned list is reused as the initial watch set. fn select_top_level_watch_dirs_capped( root: &Path, custom_ignore: &Option, custom_include: &Option, max: usize, ) -> Option> { let mut dirs = Vec::new(); for entry in ignore_walker(root, Some(1)).flatten() { if entry.depth() == 0 { // `root` itself. continue; } let path = entry.path(); if !entry.file_type().is_some_and(|ft| ft.is_dir()) { continue; } // `.git`/`.sl` are watched separately (non-recursively for `.sl`), // never as recursive workspace children. if dir_named(path, ".git") || dir_named(path, ".sl") { continue; } if passes_custom_globs(path, custom_ignore, custom_include) { if dirs.len() == max { // one past the cap return None; } dirs.push(path.to_path_buf()); } } Some(dirs) } /// Ignore-aware walker that also **prunes descent** into `.git`/`.sl` named /// dirs and custom-ignored dirs (gitignore pruning comes from the `ignore` /// crate itself). Used by per-dir selection and incremental subtree adds so /// both apply identical semantics at every depth. Never follows symlinks. fn pruning_walker( root: &Path, custom_ignore: &Option, custom_include: &Option, ) -> ignore::Walk { let mut walker = WalkBuilder::new(root); walker // Let gitignore, not the leading dot, decide. .hidden(false) .git_ignore(true) .git_global(true) .git_exclude(true) .ignore(true) .follow_links(false); let custom_ignore = custom_ignore.clone(); let custom_include = custom_include.clone(); walker.filter_entry(move |entry| { if !entry.file_type().is_some_and(|ft| ft.is_dir()) { // Files pass here; callers filter them separately. return true; } let path = entry.path(); if dir_named(path, ".git") || dir_named(path, ".sl") { // VCS metadata is watched separately (or not at all). return false; } passes_custom_globs(path, &custom_ignore, &custom_include) }); walker.build() } /// All non-ignored directories under `root` (root excluded), full depth, for /// per-dir mode — one non-recursive watch each. /// /// Selection semantics match [`select_top_level_watch_dirs`] but applied at /// every depth via [`pruning_walker`]. Returned **shallow-first** (stable /// within a depth), so a watch budget sheds the deepest directories, keeping /// coverage near the root. fn select_per_dir_watch_dirs( root: &Path, custom_ignore: &Option, custom_include: &Option, ) -> Vec { let mut dirs: Vec = pruning_walker(root, custom_ignore, custom_include) .flatten() .filter(|e| e.depth() > 0 && e.file_type().is_some_and(|ft| ft.is_dir())) .map(|e| e.into_path()) .collect(); // Walk order is DFS; re-order shallow-first so a budget cut is depth-based. dirs.sort_by_key(|d| d.components().count()); dirs } /// The watches a discovered `.git` dir needs in per-dir mode, replacing the /// fan-out mode's single recursive watch. Recursive `.git` is catastrophic on /// inotify — `objects/` (256-way fan-out) and `modules/` (submodule git dirs) /// are thousands of directories that the event filter would discard anyway. /// /// Everything [`is_git_path_for_watcher`] passes is covered: `index`, `HEAD`, /// `FETCH_HEAD`, `packed-refs`, `gc.pid` are direct children (non-recursive /// `.git` watch); `refs/heads/**` + `refs/tags/**` recursive for branch/tag /// moves. `refs` itself is non-recursive: `refs/remotes/**` (thousands of dirs /// on fetch-heavy clones) is deliberately unwatched — remote updates still /// surface via `FETCH_HEAD` and `packed-refs`. Worktree git dirs /// (`.git/worktrees/`) have no `refs/`, so they get just the non-recursive /// watch, covering their `HEAD`/`index`. fn per_dir_git_watches(git_dir: &Path) -> Vec<(PathBuf, RecursiveMode)> { let mut watches = vec![(git_dir.to_path_buf(), RecursiveMode::NonRecursive)]; let refs = git_dir.join("refs"); if refs.is_dir() { watches.push((refs.clone(), RecursiveMode::NonRecursive)); for sub in ["heads", "tags"] { let p = refs.join(sub); if p.is_dir() { watches.push((p, RecursiveMode::Recursive)); } } } watches } /// Pure set difference for watch reconciliation: dirs to add (desired but not /// live) and to remove (live but no longer desired). fn diff_watches( desired: &HashSet, live: &HashSet, ) -> (Vec, Vec) { let to_add = desired.difference(live).cloned().collect(); let to_remove = live.difference(desired).cloned().collect(); (to_add, to_remove) } /// Reconcile the recursive child watches against the current on-disk children, /// reusing [`select_top_level_watch_dirs`] so the runtime decision matches /// startup. Newly-appeared non-ignored dirs are watched; deleted, renamed-away, /// or now-ignored dirs are unwatched (so a later recreate re-arms and the set /// stays bounded). /// /// Only structural events trigger this, not ignore-rule edits, and it runs after /// the debounce window — so a freshly-ignored dir stays watched (still /// event-filtered) and a brand-new dir's pre-watch files aren't backfilled. fn reconcile_top_level_watches( debouncer: &mut Debouncer, watched: &mut HashSet, root: &Path, custom_ignore: &Option, custom_include: &Option, ) { let desired: HashSet = select_top_level_watch_dirs(root, custom_ignore, custom_include) .into_iter() .collect(); let (to_add, to_remove) = diff_watches(&desired, watched); for dir in to_add { match debouncer.watch(&dir, RecursiveMode::Recursive) { Ok(()) => { watched.insert(dir); } Err(e) => tracing::warn!("failed to watch {:?}: {:?}", dir, e), } } for dir in to_remove { // The OS often drops a deleted dir's watch already, so unwatch errors // are expected and ignored. let _ = debouncer.unwatch(&dir); watched.remove(&dir); } } /// Synthetic-`Created` backfill batch size: bounds per-event path counts for /// consumers when a huge tree appears at once (vendored checkout, `tar -x`). const BACKFILL_BATCH: usize = 512; /// Per-dir watches armed per command-loop iteration. Each `watch()` is a /// round-trip into notify's event-loop thread (~100–300µs), so the chunk /// bounds command-handling latency during startup arming (~0.1s per chunk) /// while a monorepo-scale backlog (50k+ dirs) still arms in seconds. const ARM_CHUNK: usize = 512; /// Largest pending backlog that per-dir startup arms *before* signaling /// ready. Each `watch()` costs a ~100–300µs round-trip into notify's event /// loop, so this bound keeps the added ready-latency under ~1s while giving /// typical repos (≤4k non-ignored dirs) a fully-armed watcher with **no /// startup blind window**. Bigger selections signal ready immediately after /// the root + top-level + VCS watches and arm the rest in background chunks /// (shallow-first): events under a not-yet-armed deep dir can be missed until /// its watch lands, which consumers doing an initial scan (indexer, hunk /// tracker) absorb by construction — the same window the old fan-out code had /// while notify walked each top-level subtree. const ARM_SYNC_MAX: usize = 4096; /// Arm up to [`ARM_CHUNK`] pending per-dir watches. An entry that vanished (or /// was pruned) since selection fails not-found — expected, logged at debug. An /// entry re-added by an `Update` in the meantime is skipped by the `contains` /// check. fn arm_pending_chunk( debouncer: &mut Debouncer, watched: &mut HashSet, pending: &mut std::collections::VecDeque, mode: RecursiveMode, ) { for _ in 0..ARM_CHUNK { let Some(dir) = pending.pop_front() else { break; }; if watched.contains(&dir) { continue; } match debouncer.watch(&dir, mode) { Ok(()) => { watched.insert(dir); } Err(e) => tracing::debug!("failed to arm pending watch {:?}: {:?}", dir, e), } } if pending.is_empty() { tracing::debug!( "fs_notify: watch arming complete ({} workspace dirs watched)", watched.len() ); } } /// Per-dir mode: watch a newly created directory subtree. /// /// Walk order closes the race with in-flight writers: each dir is watched /// **when yielded, before its listing is read** (pre-order), so a file landing /// after the listing is caught by the just-armed watch, and one landing before /// is caught by the backfill — files can be reported twice (consumers are /// idempotent) but never lost. Files present at walk time are emitted as /// synthetic `Created` events (batched): they arrived while the dir was /// unwatched, since the AddDirs command itself rode a ≥1-debounce-window delay /// behind the `mkdir`. /// /// Budget-aware: stops adding once `budget` is reached (warned upstream). fn add_subtree_watches( debouncer: &mut Debouncer, watched: &mut HashSet, subtree_root: &Path, custom_ignore: &Option, custom_include: &Option, budget: usize, tx: &mpsc::UnboundedSender, ) { // Symlink or vanished-since-event: nothing to watch. if !subtree_root .symlink_metadata() .is_ok_and(|m| m.file_type().is_dir()) { return; } // Already-watched roots are the common case (every event *on* a watched // dir makes it an add candidate): a fresh subtree starts unwatched, and // events *inside* a watched-but-stale subtree re-candidate their parent // anyway, so skipping here never strands a genuinely new dir. if watched.contains(subtree_root) { return; } let mut backfill: Vec = Vec::new(); let flush = |paths: &mut Vec| { if !paths.is_empty() { let _ = tx.send(RawFsEvent { paths: std::mem::take(paths), kind: FsEventKind::Created, }); } }; for entry in pruning_walker(subtree_root, custom_ignore, custom_include).flatten() { let path = entry.path(); if entry.file_type().is_some_and(|ft| ft.is_dir()) { if watched.contains(path) { continue; } if watched.len() >= budget { tracing::warn!( "fs_notify: watch budget ({budget}) reached while adding {:?}; deeper dirs unwatched", subtree_root ); break; } match debouncer.watch(path, RecursiveMode::NonRecursive) { Ok(()) => { watched.insert(path.to_path_buf()); } Err(e) => tracing::warn!("failed to watch new dir {:?}: {:?}", path, e), } } else if entry.depth() > 0 && passes_custom_globs(path, custom_ignore, custom_include) { backfill.push(path.to_path_buf()); if backfill.len() >= BACKFILL_BATCH { flush(&mut backfill); } } } flush(&mut backfill); } /// Per-dir mode: drop bookkeeping (and best-effort OS watches) for a removed /// or renamed-away directory subtree. The kernel already dropped watches on /// deleted dirs (`IN_IGNORED`), but the explicit unwatch keeps notify's /// path-keyed bookkeeping clean and — crucially for renames — frees the watch /// descriptor *before* the destination path is re-watched (see /// [`WatchCommand::Update`] ordering). fn prune_subtree_watches( debouncer: &mut Debouncer, watched: &mut HashSet, subtree_root: &Path, ) { let stale: Vec = watched .iter() .filter(|p| p.starts_with(subtree_root)) .cloned() .collect(); for dir in stale { // Usually already gone; errors expected. let _ = debouncer.unwatch(&dir); watched.remove(&dir); } } pub(crate) fn start( watch_path: PathBuf, config: FsNotifyConfig, sapling: bool, ) -> Result<(mpsc::UnboundedReceiver, FsNotifyHandle), crate::FsNotifyError> { start_with_timeout( watch_path, config, sapling, watch_strategy(), Duration::from_secs(WATCHER_INIT_TIMEOUT_SECS), ) } /// Start with a custom timeout and explicit strategy (tests pass these /// directly to avoid process-global env races). `sapling` is the resolved /// kill-switch, threaded from `FsEventSource::start_on`. pub(crate) fn start_with_timeout( watch_path: PathBuf, config: FsNotifyConfig, sapling: bool, strategy: WatchStrategy, init_timeout: Duration, ) -> Result<(mpsc::UnboundedReceiver, FsNotifyHandle), crate::FsNotifyError> { let progress = Arc::new(Mutex::new(StartProgress::new())); let (tx, rx) = mpsc::unbounded_channel(); let debounce_duration = Duration::from_millis(config.debounce_ms); // `.git/` (and, when `sapling`, `.sl/wlock`) pass through; the source // classifies internally. let watch_vcs = true; let (custom_ignore, custom_include) = build_globsets(&config.ignore_patterns); // Arc so the watcher thread and its debouncer callback share, not copy, them. let custom_ignore = Arc::new(custom_ignore); let custom_include = Arc::new(custom_include); // Canonicalize once: notify echoes event paths under the watched path, but // macOS FSEvents resolves symlinks, so a raw (symlinked/relative) root would // never match `parent() == root` and dynamic watching would silently break. let watch_path = dunce::canonicalize(&watch_path).unwrap_or(watch_path); tracing::debug!("fs_notify: starting watcher under {:?}", watch_path); // Channel to signal when watcher is ready let (ready_tx, ready_rx) = std::sync::mpsc::channel::>>(); // Carries reconcile requests from the debouncer callback to the owning // thread, plus the shutdown signal from `FsNotifyHandle`. let (cmd_tx, cmd_rx) = std::sync::mpsc::channel::(); let cmd_tx_cb = cmd_tx.clone(); let cmd_tx_for_handle = cmd_tx; // Live OS-watch count, shared with the handle for stats/benchmarks. let watch_count = Arc::new(AtomicUsize::new(0)); let watch_count_thread = Arc::clone(&watch_count); // Synthetic-backfill sender for per-dir subtree adds (the debouncer // callback owns the primary sender). let backfill_tx = tx.clone(); if let Ok(mut p) = progress.lock() { p.set_stage("spawning_watcher_thread"); } let progress_for_thread = progress.clone(); let watcher_loop = move || { let update_stage = |stage: &'static str| { if let Ok(mut p) = progress_for_thread.lock() { p.set_stage(stage); } }; update_stage("watcher_thread_started"); let mut gitignore_cache = GitignoreCache::default(); let watch_path_cb = watch_path.clone(); let custom_ignore_cb = Arc::clone(&custom_ignore); let custom_include_cb = Arc::clone(&custom_include); // Use NoCache to avoid walking the entire directory tree for file ID tracking. // This prevents multi-GB memory usage on large repos. Trade-off: rename events // may appear as Remove+Create pairs instead of a single Rename event. update_stage("creating_debouncer"); let debouncer_result = new_debouncer_opt::<_, notify::RecommendedWatcher, _>( debounce_duration, None, move |result: DebounceEventResult| match result { Ok(events) => { // Per-path (not per-event) so gitignored paths can't leak via // multi-path debounced events. let mut needs_reconcile = false; let mut pruned: Vec = Vec::new(); let mut added: Vec = Vec::new(); for mut event in merge_events(events) { event.paths.retain(|path| { if let Some(ref include_set) = *custom_include_cb && include_set.is_match(path) { return true; } if gitignore_cache.is_ignored(path, watch_vcs, sapling) { return false; } if let Some(ref ignore_set) = *custom_ignore_cb && ignore_set.is_match(path) { return false; } true }); if event.paths.is_empty() { continue; } // Post-retain, so ignored paths never grow the watch set. match strategy { WatchStrategy::Fanout => { if event_triggers_reconcile( event.kind, &event.paths, &watch_path_cb, ) { needs_reconcile = true; } } WatchStrategy::PerDir => scan_per_dir_updates( event.kind, &event.paths, &mut pruned, &mut added, ), } let _ = tx.send(event); } // One command per batch, not per event, to coalesce bursts. if needs_reconcile { let _ = cmd_tx_cb.send(WatchCommand::Reconcile); } if !pruned.is_empty() || !added.is_empty() { let _ = cmd_tx_cb.send(WatchCommand::Update { pruned, added }); } } Err(errors) => { for e in errors { tracing::warn!("fs_notify error: {:?}", e); } } }, NoCache, // Don't follow symlinks: watches can't leave the workspace, and (a // behavior change) in-workspace symlinked dirs aren't traversed, so // file events under them no longer surface. notify::Config::default().with_follow_symlinks(false), ); match debouncer_result { Ok(mut debouncer) => { update_stage("adding_watches"); let per_dir = strategy == WatchStrategy::PerDir; let budget = max_watch_budget(); // Initial layout per strategy (see module docs). `initial` is // `Some(dirs)` whenever the root watch is non-recursive. let initial = match strategy { WatchStrategy::PerDir => { let mut dirs = select_per_dir_watch_dirs(&watch_path, &custom_ignore, &custom_include); if dirs.len() > budget { tracing::warn!( "fs_notify: {} non-ignored dirs exceed watch budget {budget}; \ shedding the deepest (raise with KIGI_FSNOTIFY_MAX_WATCHES)", dirs.len() ); dirs.truncate(budget); } Some(dirs) } // Fan-out vs. recursive-root decision on the non-ignored // count; stops early past the cap (see `MAX_TOP_LEVEL_FANOUT`). WatchStrategy::Fanout => select_top_level_watch_dirs_capped( &watch_path, &custom_ignore, &custom_include, MAX_TOP_LEVEL_FANOUT, ), }; let root_non_recursive = initial.is_some(); let root_mode = if root_non_recursive { // Non-recursive: catches top-level files and the // creation/removal of top-level directories. RecursiveMode::NonRecursive } else { RecursiveMode::Recursive }; // Per-dir children are leaves of the layout; fan-out children // are kernel-recursive subtrees. let child_mode = if per_dir { RecursiveMode::NonRecursive } else { RecursiveMode::Recursive }; if let Err(e) = debouncer.watch(&watch_path, root_mode) { tracing::error!("failed to watch root: {:?}", e); let _ = ready_tx.send(Err(Box::new(e))); return; } // Reuse the already-computed selection as the initial watch // set. Fan-out installs everything synchronously (≤64+1 // `watch()` calls). Per-dir installs only the root's immediate // children synchronously — each `watch()` is a round-trip into // notify's event-loop thread, and a monorepo-scale selection // (tens of thousands of dirs) would block readiness past the // init timeout — the rest arms in the command loop in // shallow-first chunks (see `ARM_CHUNK`) right after `ready`. let mut watched_dirs: HashSet = HashSet::new(); let mut pending_dirs: std::collections::VecDeque = std::collections::VecDeque::new(); if let Some(dirs) = initial { let sync_head: Vec = if per_dir { let (head, tail): (Vec, Vec) = dirs .into_iter() .partition(|d| d.parent() == Some(watch_path.as_path())); // Still shallow-first. pending_dirs = tail.into(); head } else { dirs }; for dir in sync_head { match debouncer.watch(&dir, child_mode) { Ok(()) => { watched_dirs.insert(dir); } Err(e) => tracing::warn!("failed to watch {:?}: {:?}", dir, e), } } } // Watch `.git` for HEAD/index/lock events; it is excluded from // the workspace children. Fan-out: one recursive watch (cheap // on kernel-recursive backends). Per-dir: surgical watches — // recursive `.git` costs one inotify watch per dir under it // (`objects/` + `modules/` = 13k+ dirs on submodule-heavy // repos) for events the filter would drop anyway. let mut vcs_watches = 0usize; // Retained beyond the watch setup: the per-dir Update arm // excludes paths under these from workspace watch management // (their events pass the VCS filter, but their dirs belong to // the surgical VCS watches, never `watched_dirs`). let git_dir = if watch_vcs { find_git_dir(&watch_path) } else { None }; let sl_dir = if watch_vcs && sapling { find_sl_dir(&watch_path) } else { None }; if let Some(gd) = git_dir .as_deref() .filter(|gd| should_watch_separate_vcs_dir(root_non_recursive, gd, &watch_path)) { let git_watches = if per_dir { per_dir_git_watches(gd) } else { vec![(gd.to_path_buf(), RecursiveMode::Recursive)] }; for (p, mode) in git_watches { if let Err(e) = debouncer.watch(&p, mode) { tracing::warn!("failed to watch git path {:?}: {:?}", p, e); } else { vcs_watches += 1; } } tracing::debug!("fs_notify: watching git dir {:?}", gd); } // Watch `.sl` non-recursively: its sole whitelisted marker // (`wlock`) is a direct child, so `.sl/store` is never walked. if let Some(sd) = sl_dir .as_deref() .filter(|sd| should_watch_separate_vcs_dir(root_non_recursive, sd, &watch_path)) { if let Err(e) = debouncer.watch(sd, RecursiveMode::NonRecursive) { tracing::warn!("failed to watch sl dir {:?}: {:?}", sd, e); } else { vcs_watches += 1; tracing::debug!("fs_notify: watching sl dir {:?}", sd); } } // Small backlogs are armed fully before declaring readiness // (no blind window); monorepo-scale ones continue in the // command loop below (see `ARM_SYNC_MAX`). if pending_dirs.len() <= ARM_SYNC_MAX { while !pending_dirs.is_empty() { arm_pending_chunk( &mut debouncer, &mut watched_dirs, &mut pending_dirs, child_mode, ); } } watch_count_thread.store(1 + watched_dirs.len() + vcs_watches, Ordering::Relaxed); tracing::debug!( "fs_notify started: watching {:?} (strategy={:?}, {} workspace dirs armed + {} pending + {} vcs watches, {}ms debounce)", watch_path, strategy, watched_dirs.len(), pending_dirs.len(), vcs_watches, debounce_duration.as_millis() ); // Signal ready once the root, its immediate children, the VCS // watches, and everything the grace period covered are // established (any per-dir remainder arms in chunks below; // fan-out never has anything pending). update_stage("signaling_ready"); let _ = ready_tx.send(Ok(())); update_stage("running"); // Handle one non-shutdown command. Returns `false` on shutdown. let handle_command = |cmd: WatchCommand, debouncer: &mut Debouncer, watched_dirs: &mut HashSet| -> bool { match cmd { WatchCommand::Reconcile => { // Fan-out only (the callback never sends it in // per-dir mode; guard anyway). if !per_dir && root_non_recursive { reconcile_top_level_watches( debouncer, watched_dirs, &watch_path, &custom_ignore, &custom_include, ); } } WatchCommand::Update { pruned, added } => { if per_dir { // Prune before add (rename wd re-binding; see // `WatchCommand::Update`). The `contains` gate // makes removed *files* O(1); a watched dir // whose ancestor was shed by the budget can // strand descendants in bookkeeping — the // kernel already dropped those watches, and a // recreate re-arms via `add_subtree_watches`' // own `contains` check. for p in &pruned { if watched_dirs.contains(p) { prune_subtree_watches(debouncer, watched_dirs, p); } } for a in &added { // The root is watched separately and always // live; without this guard a metadata touch // on the root (`chmod`/`touch`) would walk // the whole workspace as an "add". Dirs // under `.git`/`.sl` (whose *events* pass // the VCS filter, e.g. a new `refs/heads/ // feature/` namespace) belong to the // surgical VCS watches, not the workspace // set. if *a == watch_path || git_dir.as_deref().is_some_and(|d| a.starts_with(d)) || sl_dir.as_deref().is_some_and(|d| a.starts_with(d)) { continue; } add_subtree_watches( debouncer, watched_dirs, a, &custom_ignore, &custom_include, budget, &backfill_tx, ); } } } WatchCommand::Shutdown => return false, } true }; // Own the debouncer; alternate between draining commands and // arming pending per-dir watches in shallow-first chunks, so a // huge initial selection can't starve prune/add commands (or // shutdown). Once `pending_dirs` empties this degrades to a // plain blocking `recv` loop. 'run: loop { // Drain whatever is queued without blocking. loop { match cmd_rx.try_recv() { Ok(cmd) => { if !handle_command(cmd, &mut debouncer, &mut watched_dirs) { break 'run; } } Err(std::sync::mpsc::TryRecvError::Empty) => break, Err(std::sync::mpsc::TryRecvError::Disconnected) => break 'run, } } if pending_dirs.is_empty() { watch_count_thread .store(1 + watched_dirs.len() + vcs_watches, Ordering::Relaxed); // Nothing to arm: block until the next command. match cmd_rx.recv() { Ok(cmd) => { if !handle_command(cmd, &mut debouncer, &mut watched_dirs) { break 'run; } } Err(_) => break 'run, } } else { arm_pending_chunk( &mut debouncer, &mut watched_dirs, &mut pending_dirs, child_mode, ); watch_count_thread .store(1 + watched_dirs.len() + vcs_watches, Ordering::Relaxed); } } watch_count_thread.store(1 + watched_dirs.len() + vcs_watches, Ordering::Relaxed); tracing::debug!("fs_notify stopped"); } Err(e) => { tracing::error!("failed to create debouncer: {:?}", e); let _ = ready_tx.send(Err(Box::new(e))); } } }; // Recoverable spawn: a host out of threads must surface as an error the // caller can degrade on, not a `std::thread::spawn` panic. let thread = std::thread::Builder::new() .name("fsnotify-watcher".into()) .spawn(watcher_loop) .map_err(|e| crate::FsNotifyError::WatcherStart(Box::new(e)))?; // Wait for watcher to be ready (with timeout) if let Ok(mut p) = progress.lock() { p.set_stage("waiting_for_ready"); } match ready_rx.recv_timeout(init_timeout) { Ok(Ok(())) => {} Ok(Err(e)) => return Err(crate::FsNotifyError::WatcherStart(e)), Err(_) => { let (stage, stage_elapsed, total_elapsed, timeline) = progress.lock().map(|p| p.snapshot()).unwrap_or(( "unknown", Duration::from_secs(0), Duration::from_secs(0), Vec::new(), )); tracing::debug!( "watcher start timed out ({}s): stage={}, stage_elapsed={:?}, total_elapsed={:?}, timeline={:?}", init_timeout.as_secs(), stage, stage_elapsed, total_elapsed, timeline ); // No `FsNotifyHandle` owns the thread on this path, so queue a // Shutdown: when the slow setup finishes and the thread reaches its // recv loop, it self-terminates and releases its watches instead of // leaking (the callback holds the other sender, so it never // disconnects on its own). let _ = cmd_tx_for_handle.send(WatchCommand::Shutdown); return Err(crate::FsNotifyError::Timeout); } } Ok(( rx, FsNotifyHandle { cmd_tx: Some(cmd_tx_for_handle), thread: Some(thread), watch_count, }, )) } #[cfg(test)] mod tests { use super::*; use kigi_tracing_macros::teprintln; use std::path::PathBuf; #[test] fn test_map_event_kind() { use notify::event::{CreateKind, ModifyKind, RemoveKind}; assert_eq!( map_event_kind(&EventKind::Create(CreateKind::File)), Some(FsEventKind::Created) ); assert_eq!( map_event_kind(&EventKind::Modify(ModifyKind::Data( notify::event::DataChange::Content ))), Some(FsEventKind::Modified) ); assert_eq!( map_event_kind(&EventKind::Modify(ModifyKind::Name( notify::event::RenameMode::Both ))), Some(FsEventKind::Renamed) ); assert_eq!( map_event_kind(&EventKind::Remove(RemoveKind::File)), Some(FsEventKind::Removed) ); assert_eq!(map_event_kind(&EventKind::Other), None); } // Integration tests with real filesystem and debouncer // These tests are serialized because macOS FSEvents has limited resources // when many watchers are created simultaneously. mod integration { use super::*; use serial_test::serial; use std::fs; use std::time::Duration; use tempfile::TempDir; /// Default debounce time for tests const TEST_DEBOUNCE_MS: u64 = 50; /// Max time to wait for events after debounce (debounce + buffer) const EVENT_WAIT_MS: u64 = 300; /// Timeout for watcher initialization in tests const TEST_INIT_TIMEOUT: Duration = Duration::from_secs(15); /// Number of retries for starting watcher (helps with flaky FSEvents) const START_RETRIES: usize = 3; /// Start a watcher with retry logic for flaky FSEvents. fn start_with_retry( watch_path: PathBuf, config: FsNotifyConfig, ) -> Result< ( tokio::sync::mpsc::UnboundedReceiver, FsNotifyHandle, ), crate::FsNotifyError, > { start_with_retry_strategy(watch_path, config, watch_strategy()) } /// Like [`start_with_retry`] but with an explicit strategy, so tests /// can exercise both layouts without process-global env races. fn start_with_retry_strategy( watch_path: PathBuf, config: FsNotifyConfig, strategy: WatchStrategy, ) -> Result< ( tokio::sync::mpsc::UnboundedReceiver, FsNotifyHandle, ), crate::FsNotifyError, > { let mut last_error = None; for attempt in 1..=START_RETRIES { match start_with_timeout( watch_path.clone(), config.clone(), true, strategy, TEST_INIT_TIMEOUT, ) { Ok(result) => return Ok(result), Err(e) => { teprintln!( "Watcher start attempt {}/{} failed: {}", attempt, START_RETRIES, e ); last_error = Some(e); std::thread::sleep(Duration::from_millis(100)); } } } Err(last_error.unwrap_or_else(|| { crate::FsNotifyError::WatcherStart( std::io::Error::other("failed to start watcher").into(), ) })) } /// Helper to collect events with timeout, with early exit once we get events /// and a quiet period passes without new ones. fn collect_events_smart( rx: &mut tokio::sync::mpsc::UnboundedReceiver, max_wait: Duration, quiet_period: Duration, ) -> Vec { let mut events = Vec::new(); let deadline = std::time::Instant::now() + max_wait; let mut last_event_time = std::time::Instant::now(); while std::time::Instant::now() < deadline { match rx.try_recv() { Ok(event) => { events.push(event); last_event_time = std::time::Instant::now(); } Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { // If we have events and quiet period elapsed, return early if !events.is_empty() && last_event_time.elapsed() >= quiet_period { return events; } std::thread::sleep(Duration::from_millis(10)); } Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, } } events } /// Collect events with default timing fn collect_events( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Vec { collect_events_smart( rx, Duration::from_millis(EVENT_WAIT_MS), Duration::from_millis(50), ) } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_debouncer_create_file() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Create a file let test_file = watch_path.join("test.txt"); fs::write(&test_file, "hello").unwrap(); let events = collect_events(&mut rx); // Should have at least one Create event let create_events: Vec<_> = events .iter() .filter(|e| e.kind == FsEventKind::Created) .collect(); assert!( !create_events.is_empty(), "Expected Create event, got: {:?}", events ); // The path should contain our file let has_test_file = create_events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("test.txt"))); assert!(has_test_file, "Create event should contain test.txt"); } #[test] #[serial] fn test_debouncer_modify_file() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // Create file before starting watcher let test_file = watch_path.join("existing.txt"); fs::write(&test_file, "initial").unwrap(); // Small delay to ensure file is stable before watcher starts std::thread::sleep(Duration::from_millis(50)); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Modify the file fs::write(&test_file, "modified content").unwrap(); let events = collect_events(&mut rx); // Should have an event for the file (Modify on most platforms, // but macOS FSEvents may report Create in some cases) let has_file_event = events.iter().any(|e| { (e.kind == FsEventKind::Modified || e.kind == FsEventKind::Created) && e.paths.iter().any(|p| p.ends_with("existing.txt")) }); assert!( has_file_event, "Expected Modify or Create event for existing.txt, got: {:?}", events ); } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_debouncer_delete_file() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // Create file before starting watcher let test_file = watch_path.join("to_delete.txt"); fs::write(&test_file, "delete me").unwrap(); std::thread::sleep(Duration::from_millis(50)); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path, config).unwrap(); // Delete the file fs::remove_file(&test_file).unwrap(); let events = collect_events(&mut rx); // Should have a Remove event (might also have Modify on some platforms) let remove_events: Vec<_> = events .iter() .filter(|e| e.kind == FsEventKind::Removed) .collect(); // Note: On macOS, delete sometimes shows as Modify first let has_remove_or_modify = !remove_events.is_empty() || events.iter().any(|e| { e.kind == FsEventKind::Modified && e.paths.iter().any(|p| p.ends_with("to_delete.txt")) }); assert!( has_remove_or_modify, "Expected Remove or Modify event for deleted file, got: {:?}", events ); } #[test] #[serial] fn test_debouncer_rename_file() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // Create file before starting watcher let old_path = watch_path.join("old_name.txt"); fs::write(&old_path, "rename me").unwrap(); std::thread::sleep(Duration::from_millis(50)); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Rename the file let new_path = watch_path.join("new_name.txt"); fs::rename(&old_path, &new_path).unwrap(); let events = collect_events(&mut rx); // On macOS FSEvents, rename may come as: // - Rename event (ideal) // - Create event for new file (FSEvents consolidation) // - Remove + Create pair let has_rename = events.iter().any(|e| e.kind == FsEventKind::Renamed); let has_new_file = events.iter().any(|e| { (e.kind == FsEventKind::Created || e.kind == FsEventKind::Renamed) && e.paths.iter().any(|p| p.ends_with("new_name.txt")) }); assert!( has_rename || has_new_file, "Expected Rename event or Create for new file, got: {:?}", events ); } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_debouncer_multiple_rapid_creates() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); let config = FsNotifyConfig { // Slightly longer debounce to batch events debounce_ms: 50, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Create multiple files rapidly for i in 0..5 { let file = watch_path.join(format!("file_{}.txt", i)); fs::write(&file, format!("content {}", i)).unwrap(); } // Use longer timeout for batched events let events = collect_events_smart( &mut rx, Duration::from_millis(200), Duration::from_millis(50), ); // Should have Create events for all files let created_files: std::collections::HashSet<_> = events .iter() .filter(|e| e.kind == FsEventKind::Created) .flat_map(|e| e.paths.iter()) .filter_map(|p| p.file_name()) .map(|n| n.to_string_lossy().to_string()) .collect(); // All 5 files should be in create events for i in 0..5 { let filename = format!("file_{}.txt", i); assert!( created_files.contains(&filename), "Missing create event for {}, got: {:?}", filename, created_files ); } } #[test] #[serial] fn test_debouncer_gitignore_respected() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // Create .gitignore first let gitignore = watch_path.join(".gitignore"); fs::write(&gitignore, "*.log\ntarget/\n").unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Create an ignored file and a normal file let log_file = watch_path.join("debug.log"); let txt_file = watch_path.join("readme.txt"); fs::write(&log_file, "log content").unwrap(); fs::write(&txt_file, "readme content").unwrap(); let events = collect_events(&mut rx); // Should NOT have event for .log file (gitignored) let has_log = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with(".log"))); // Should have event for .txt file let has_txt = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("readme.txt"))); assert!( !has_log, "Should not receive events for gitignored .log files" ); assert!( has_txt, "Should receive events for non-ignored .txt files, got: {:?}", events ); } #[test] #[serial] fn test_debouncer_custom_ignore_patterns() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec!["*.tmp".to_string(), "cache/**".to_string()], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Create ignored and non-ignored files fs::write(watch_path.join("test.tmp"), "temp").unwrap(); fs::write(watch_path.join("test.txt"), "text").unwrap(); let events = collect_events(&mut rx); let has_tmp = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("test.tmp"))); let has_txt = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("test.txt"))); assert!(!has_tmp, "Should not receive events for *.tmp files"); assert!( has_txt, "Should receive events for .txt files, got: {:?}", events ); } #[test] #[serial] fn test_debouncer_subdirectory() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // Create subdirectory let sub_dir = watch_path.join("src"); fs::create_dir(&sub_dir).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path, config).unwrap(); // Create file in subdirectory let nested_file = sub_dir.join("main.rs"); fs::write(&nested_file, "fn main() {}").unwrap(); let events = collect_events(&mut rx); // Should have Create event for nested file let has_nested = events.iter().any(|e| { e.kind == FsEventKind::Created && e.paths.iter().any(|p| p.ends_with("main.rs")) }); assert!( has_nested, "Should receive Create event for file in subdirectory, got: {:?}", events ); } #[test] #[serial] fn test_handle_drop_stops_watcher() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ..Default::default() }; let (mut rx, handle) = start_with_retry(watch_path.clone(), config).unwrap(); // drain startup stragglers let _ = collect_events(&mut rx); // Drop joins the watcher thread, which drops the debouncer and the // event sender. Run it on a watchdog thread so a broken Shutdown // path (hung join) fails fast as an assertion rather than hanging // the whole test/CI. let dropper = std::thread::spawn(move || drop(handle)); let drop_deadline = std::time::Instant::now() + Duration::from_secs(5); while !dropper.is_finished() { assert!( std::time::Instant::now() < drop_deadline, "FsNotifyHandle::drop did not return within 5s — shutdown is broken" ); std::thread::sleep(Duration::from_millis(20)); } dropper.join().unwrap(); // The receiver must observe disconnection within a bounded time — // this is what proves the watcher actually stopped. let deadline = std::time::Instant::now() + Duration::from_secs(2); let disconnected = loop { match rx.try_recv() { Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break true, // drain any straggler before disconnect Ok(_) => {} Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { if std::time::Instant::now() >= deadline { break false; } std::thread::sleep(Duration::from_millis(10)); } } }; assert!( disconnected, "event channel must disconnect after the handle is dropped" ); // And a post-drop write must not surface (watcher is gone) — this // would still arrive if shutdown had not torn down the watch. fs::write(watch_path.join("after_drop.txt"), "test").unwrap(); std::thread::sleep(Duration::from_millis(EVENT_WAIT_MS)); let after = collect_events_smart( &mut rx, Duration::from_millis(100), Duration::from_millis(20), ); assert!( !after .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("after_drop.txt"))), "no event should surface after the watcher is dropped, got: {after:?}" ); } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_debouncer_negation_pattern_include() { // Test that negation patterns (!) override ignore patterns let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, // Ignore all .log files except important.log ignore_patterns: vec!["*.log".to_string(), "!important.log".to_string()], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Create both ignored and included files fs::write(watch_path.join("debug.log"), "debug").unwrap(); fs::write(watch_path.join("important.log"), "important").unwrap(); fs::write(watch_path.join("test.txt"), "text").unwrap(); let events = collect_events(&mut rx); let has_debug_log = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("debug.log"))); let has_important_log = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("important.log"))); let has_txt = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("test.txt"))); assert!(!has_debug_log, "debug.log should be ignored"); assert!( has_important_log, "important.log should be included via negation" ); assert!(has_txt, "test.txt should be included"); } #[test] #[serial] fn test_debouncer_nested_gitignore() { // Test that nested .gitignore files are respected let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // Create nested directory structure let sub_dir = watch_path.join("src"); fs::create_dir(&sub_dir).unwrap(); // Root .gitignore ignores *.tmp fs::write(watch_path.join(".gitignore"), "*.tmp\n").unwrap(); // Nested .gitignore ignores *.bak fs::write(sub_dir.join(".gitignore"), "*.bak\n").unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path, config).unwrap(); // Create files fs::write(sub_dir.join("test.tmp"), "tmp").unwrap(); fs::write(sub_dir.join("test.bak"), "bak").unwrap(); fs::write(sub_dir.join("test.rs"), "rs").unwrap(); let events = collect_events(&mut rx); let has_tmp = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("test.tmp"))); let has_bak = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("test.bak"))); let has_rs = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("test.rs"))); assert!(!has_tmp, "*.tmp should be ignored by root .gitignore"); assert!(!has_bak, "*.bak should be ignored by nested .gitignore"); assert!(has_rs, "*.rs should not be ignored, got: {:?}", events); } #[test] #[serial] // Flaky on macOS due to recursive watcher behavior #[ignore] fn test_debouncer_git_directory_ignored() { // .git directory contents should always be ignored let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // Create .git directory let git_dir = watch_path.join(".git"); fs::create_dir(&git_dir).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Create files inside .git and outside fs::write(git_dir.join("config"), "git config").unwrap(); fs::write(watch_path.join("README.md"), "readme").unwrap(); let events = collect_events(&mut rx); let has_git_config = events .iter() .any(|e| e.paths.iter().any(|p| p.to_string_lossy().contains(".git"))); let has_readme = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("README.md"))); assert!(!has_git_config, ".git directory contents should be ignored"); assert!( has_readme, "README.md should be included, got: {:?}", events ); } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_debouncer_create_directory() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Create a directory let new_dir = watch_path.join("new_folder"); fs::create_dir(&new_dir).unwrap(); let events = collect_events(&mut rx); // Should have Create event for the directory let create_events: Vec<_> = events .iter() .filter(|e| e.kind == FsEventKind::Created) .collect(); let has_new_folder = create_events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("new_folder"))); assert!( has_new_folder, "Should receive Create event for new directory, got: {:?}", events ); } #[test] #[serial] fn test_debouncer_deeply_nested_file() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // Create deeply nested directory structure let deep_dir = watch_path.join("a").join("b").join("c").join("d"); fs::create_dir_all(&deep_dir).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path, config).unwrap(); // Create file in deeply nested directory let deep_file = deep_dir.join("deep.txt"); fs::write(&deep_file, "deep content").unwrap(); let events = collect_events(&mut rx); let has_deep_file = events.iter().any(|e| { e.kind == FsEventKind::Created && e.paths.iter().any(|p| p.ends_with("deep.txt")) }); assert!( has_deep_file, "Should receive Create event for deeply nested file, got: {:?}", events ); } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_top_level_gitignored_target_never_surfaces() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // Exclude `target/` via `.git/info/exclude` (not `.gitignore`): the // per-event GitignoreCache ignores it, so only watch-level exclusion // keeps target paths out — this discriminates the new behavior. fs::create_dir_all(watch_path.join(".git/info")).unwrap(); fs::write(watch_path.join(".git/info/exclude"), "target/\n").unwrap(); let target = watch_path.join("target"); fs::create_dir_all(&target).unwrap(); let src = watch_path.join("src"); fs::create_dir_all(&src).unwrap(); std::thread::sleep(Duration::from_millis(50)); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); for i in 0..50 { fs::write(target.join(format!("artifact_{i}.o")), "x").unwrap(); } // A non-ignored write proves the watcher is alive and discriminates // this assertion from a watcher that simply emits nothing. fs::write(src.join("main.rs"), "fn main() {}").unwrap(); let events = collect_events(&mut rx); let has_target = events .iter() .any(|e| e.paths.iter().any(|p| p.starts_with(&target))); let has_src = events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("main.rs"))); assert!( !has_target, "no event should surface for excluded target/, got: {events:?}" ); assert!( has_src, "control: src/main.rs event should surface, got: {events:?}" ); } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_fallback_mode_watches_top_level_dir() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // > cap non-ignored top-level dirs forces the recursive-root // fallback, which DOES watch the `.git/info/exclude`d `target/` — the // opposite of the fan-out test, pinning the trade-off. fs::create_dir_all(watch_path.join(".git/info")).unwrap(); fs::write(watch_path.join(".git/info/exclude"), "target/\n").unwrap(); for i in 0..=MAX_TOP_LEVEL_FANOUT { fs::create_dir_all(watch_path.join(format!("pkg{i}"))).unwrap(); } let target = watch_path.join("target"); fs::create_dir_all(&target).unwrap(); std::thread::sleep(Duration::from_millis(50)); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // A nested file under a top-level dir must surface — the recursive // root watch covers the whole tree in fallback mode. fs::write(watch_path.join("pkg0").join("lib.rs"), "// x").unwrap(); // The excluded top-level dir IS watched in fallback (only fan-out // excludes it at the watch level), so its event surfaces here. fs::write(target.join("artifact.o"), "x").unwrap(); let events = collect_events(&mut rx); let has_nested = events.iter().any(|e| { e.kind == FsEventKind::Created && e.paths.iter().any(|p| p.ends_with("lib.rs")) }); let has_target = events .iter() .any(|e| e.paths.iter().any(|p| p.starts_with(&target))); assert!( has_nested, "fallback recursive watch must surface nested files, got: {events:?}" ); assert!( has_target, "fallback watches top-level dirs the fan-out path would exclude, got: {events:?}" ); } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_new_top_level_dir_contents_watched_dynamically() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Created after startup: the root is watched non-recursively, so the // watcher must add a recursive watch for this dir dynamically or its // contents would be missed. let new_dir = watch_path.join("late_crate"); fs::create_dir(&new_dir).unwrap(); // Let the dir-create be observed and the recursive watch added. std::thread::sleep(Duration::from_millis(EVENT_WAIT_MS)); let _ = collect_events(&mut rx); let nested = new_dir.join("lib.rs"); fs::write(&nested, "// new").unwrap(); let events = collect_events(&mut rx); let has_nested = events.iter().any(|e| { e.kind == FsEventKind::Created && e.paths.iter().any(|p| p.ends_with("lib.rs")) }); assert!( has_nested, "contents of a dynamically-created top-level dir must be watched, got: {events:?}" ); } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_moved_in_top_level_dir_is_watched() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); // A populated dir prepared OUTSIDE the watch root. let outside = TempDir::new().unwrap(); let staged = outside.path().join("moved_crate"); fs::create_dir_all(&staged).unwrap(); fs::write(staged.join("existing.rs"), "// pre-existing").unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Move it in: surfaces as a Renamed (IN_MOVED_TO) event, which must // trigger reconcile and add a recursive watch. let dest = watch_path.join("moved_crate"); fs::rename(&staged, &dest).unwrap(); std::thread::sleep(Duration::from_millis(EVENT_WAIT_MS)); let _ = collect_events(&mut rx); // A file created after the move must surface (proves it's watched). fs::write(dest.join("new.rs"), "// after move").unwrap(); let events = collect_events(&mut rx); let has_new = events.iter().any(|e| { e.kind == FsEventKind::Created && e.paths.iter().any(|p| p.ends_with("new.rs")) }); assert!( has_new, "a moved-in top-level dir must be watched, got: {events:?}" ); } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_deleted_and_recreated_top_level_dir_rewatched() { let temp_dir = TempDir::new().unwrap(); let watch_path = dunce::canonicalize(temp_dir.path()).unwrap(); let dir = watch_path.join("pkg"); fs::create_dir(&dir).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; let (mut rx, _handle) = start_with_retry(watch_path.clone(), config).unwrap(); // Delete the watched dir, then recreate it with the same name. fs::remove_dir_all(&dir).unwrap(); std::thread::sleep(Duration::from_millis(EVENT_WAIT_MS)); let _ = collect_events(&mut rx); fs::create_dir(&dir).unwrap(); std::thread::sleep(Duration::from_millis(EVENT_WAIT_MS)); let _ = collect_events(&mut rx); // The watch must be re-armed: a file in the recreated dir surfaces. fs::write(dir.join("again.rs"), "// recreated").unwrap(); let events = collect_events(&mut rx); let has_file = events.iter().any(|e| { e.kind == FsEventKind::Created && e.paths.iter().any(|p| p.ends_with("again.rs")) }); assert!( has_file, "a deleted+recreated top-level dir must be re-watched, got: {events:?}" ); } #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] #[cfg(unix)] fn test_non_canonical_root_dynamic_watch() { // The watcher must canonicalize its root so dynamic watching works // even when started on a non-canonical (symlinked) path. let temp_dir = TempDir::new().unwrap(); let real = dunce::canonicalize(temp_dir.path()).unwrap(); let link = real.join("link_root"); let real_root = real.join("real_root"); fs::create_dir(&real_root).unwrap(); std::os::unix::fs::symlink(&real_root, &link).unwrap(); let config = FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }; // Start on the SYMLINKED (non-canonical) path. let (mut rx, _handle) = start_with_retry(link.clone(), config).unwrap(); let new_dir = link.join("late"); fs::create_dir(&new_dir).unwrap(); std::thread::sleep(Duration::from_millis(EVENT_WAIT_MS)); let _ = collect_events(&mut rx); fs::write(new_dir.join("f.rs"), "// x").unwrap(); let events = collect_events(&mut rx); // Discriminating: without root canonicalization the event path would // be reported under the symlink (`link`), not the real dir. let has_canonical_file = events.iter().any(|e| { e.paths .iter() .any(|p| p.starts_with(&real_root) && p.ends_with("f.rs")) }); assert!( has_canonical_file, "dynamic watching must work and report canonical paths on a non-canonical root, got: {events:?}" ); } // per-dir strategy (Linux default; forced here so it runs on any // platform without process-global env races) /// Watch-count accounting: nested gitignored dirs cost zero watches /// and `.git` costs a handful, not one per internal dir. #[test] #[serial] fn test_per_dir_watch_count_excludes_ignored_and_git_internals() { let temp = TempDir::new().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); fs::create_dir_all(root.join(".git/objects/ab")).unwrap(); fs::create_dir_all(root.join(".git/refs/heads")).unwrap(); fs::write(root.join(".gitignore"), "node_modules/\n").unwrap(); fs::create_dir_all(root.join("web/src")).unwrap(); for i in 0..20 { fs::create_dir_all(root.join(format!("web/node_modules/pkg{i}/lib"))).unwrap(); } let (_rx, handle) = start_with_retry_strategy( root.clone(), FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }, WatchStrategy::PerDir, ) .unwrap(); // root(1) + web + web/src (2) + .git,.git/refs,.git/refs/heads (3). // The 40 node_modules dirs and .git/objects contribute nothing. // Depth≥2 dirs arm asynchronously after `ready`, so poll briefly. let deadline = std::time::Instant::now() + Duration::from_secs(5); while handle.watch_count() != 6 && std::time::Instant::now() < deadline { std::thread::sleep(Duration::from_millis(20)); } assert_eq!( handle.watch_count(), 6, "per-dir watch layout must skip ignored trees and git internals" ); } /// Files inside a nested gitignored dir generate no events (there is /// no watch to generate them), while sibling source dirs still do. #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_per_dir_nested_ignored_dir_produces_no_events() { let temp = TempDir::new().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); fs::create_dir_all(root.join(".git")).unwrap(); fs::write(root.join(".gitignore"), "node_modules/\n").unwrap(); fs::create_dir_all(root.join("web/node_modules/react")).unwrap(); fs::create_dir_all(root.join("web/src")).unwrap(); let (mut rx, _handle) = start_with_retry_strategy( root.clone(), FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }, WatchStrategy::PerDir, ) .unwrap(); fs::write(root.join("web/node_modules/react/index.js"), "x").unwrap(); fs::write(root.join("web/src/app.ts"), "y").unwrap(); let events = collect_events(&mut rx); assert!( events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("app.ts"))), "non-ignored file must surface: {events:?}" ); assert!( !events.iter().any(|e| e .paths .iter() .any(|p| p.to_string_lossy().contains("node_modules"))), "ignored tree must stay silent: {events:?}" ); } /// New nested dirs get watched incrementally: files written *after* /// the watch attaches still surface, and pre-watch files backfill as /// synthetic `Created`s. #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_per_dir_new_nested_dir_watched_with_backfill() { let temp = TempDir::new().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); fs::create_dir_all(root.join("src")).unwrap(); let (mut rx, handle) = start_with_retry_strategy( root.clone(), FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }, WatchStrategy::PerDir, ) .unwrap(); let watches_before = handle.watch_count(); // Dir + immediate file: the file races the (post-debounce) watch // attach, so it must arrive via backfill. let deep = root.join("src/gen/v1"); fs::create_dir_all(&deep).unwrap(); fs::write(deep.join("early.rs"), "// early").unwrap(); // Wait out debounce + Update command processing. std::thread::sleep(Duration::from_millis(EVENT_WAIT_MS)); let early_events = collect_events(&mut rx); assert!( early_events.iter().any(|e| { e.kind == FsEventKind::Created && e.paths.iter().any(|p| p.ends_with("early.rs")) }), "pre-watch file must backfill as Created: {early_events:?}" ); // A later write proves the incremental watch is armed. fs::write(deep.join("late.rs"), "// late").unwrap(); let late_events = collect_events(&mut rx); assert!( late_events .iter() .any(|e| e.paths.iter().any(|p| p.ends_with("late.rs"))), "new nested dir must be live-watched: {late_events:?}" ); assert!( handle.watch_count() > watches_before, "watch count must grow for the new subtree" ); } /// Deleting a watched subtree shrinks the watch set (bookkeeping and /// OS watches both released). #[test] #[serial] #[ignore = "flaky in CI — fs events not reliably delivered"] fn test_per_dir_removed_subtree_pruned() { let temp = TempDir::new().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); fs::create_dir_all(root.join("pkg/a/b")).unwrap(); let (mut rx, handle) = start_with_retry_strategy( root.clone(), FsNotifyConfig { debounce_ms: TEST_DEBOUNCE_MS, ignore_patterns: vec![], }, WatchStrategy::PerDir, ) .unwrap(); let watches_before = handle.watch_count(); fs::remove_dir_all(root.join("pkg")).unwrap(); std::thread::sleep(Duration::from_millis(EVENT_WAIT_MS)); let _ = collect_events(&mut rx); assert!( handle.watch_count() < watches_before, "watch count must shrink after subtree removal: {} -> {}", watches_before, handle.watch_count() ); } } // Unit tests for merge_events and build_globsets mod merge_events_tests { use super::*; use notify::event::{CreateKind, ModifyKind, RemoveKind, RenameMode}; fn make_debounced_event(kind: EventKind, paths: Vec) -> DebouncedEvent { DebouncedEvent { event: notify::Event { kind, paths, attrs: Default::default(), }, time: std::time::Instant::now(), } } #[test] fn test_merge_single_create() { let events = vec![make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/file.txt")], )]; let merged = merge_events(events); assert_eq!(merged.len(), 1); assert_eq!(merged[0].kind, FsEventKind::Created); assert_eq!(merged[0].paths.len(), 1); assert_eq!(merged[0].paths[0], PathBuf::from("/test/file.txt")); } #[test] fn test_merge_multiple_creates_same_path() { // Multiple creates for same path should result in single create let events = vec![ make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/file.txt")], ), make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/file.txt")], ), ]; let merged = merge_events(events); // Should be merged into one event let create_count: usize = merged .iter() .filter(|e| e.kind == FsEventKind::Created) .map(|e| e.paths.len()) .sum(); assert_eq!(create_count, 1, "Duplicate creates should be merged"); } #[test] fn test_merge_create_then_modify() { // Create followed by modify should remain Create let events = vec![ make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/file.txt")], ), make_debounced_event( EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Content)), vec![PathBuf::from("/test/file.txt")], ), ]; let merged = merge_events(events); let path_kinds: std::collections::HashMap<_, _> = merged .iter() .flat_map(|e| e.paths.iter().map(move |p| (p.clone(), e.kind))) .collect(); assert_eq!( path_kinds.get(&PathBuf::from("/test/file.txt")), Some(&FsEventKind::Created), "Create+Modify should remain Create" ); } #[test] fn test_merge_modify_then_create() { // Modify followed by create should become Create let events = vec![ make_debounced_event( EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Content)), vec![PathBuf::from("/test/file.txt")], ), make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/file.txt")], ), ]; let merged = merge_events(events); let path_kinds: std::collections::HashMap<_, _> = merged .iter() .flat_map(|e| e.paths.iter().map(move |p| (p.clone(), e.kind))) .collect(); assert_eq!( path_kinds.get(&PathBuf::from("/test/file.txt")), Some(&FsEventKind::Created), "Modify+Create should become Create" ); } #[test] fn test_merge_any_then_remove() { // Any event followed by remove should become Remove let events = vec![ make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/file.txt")], ), make_debounced_event( EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Content)), vec![PathBuf::from("/test/file.txt")], ), make_debounced_event( EventKind::Remove(RemoveKind::File), vec![PathBuf::from("/test/file.txt")], ), ]; let merged = merge_events(events); let path_kinds: std::collections::HashMap<_, _> = merged .iter() .flat_map(|e| e.paths.iter().map(move |p| (p.clone(), e.kind))) .collect(); assert_eq!( path_kinds.get(&PathBuf::from("/test/file.txt")), Some(&FsEventKind::Removed), "Any+Remove should become Remove" ); } #[test] fn test_merge_any_then_rename() { // Any event followed by rename should become Rename let events = vec![ make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/file.txt")], ), make_debounced_event( EventKind::Modify(ModifyKind::Name(RenameMode::Both)), vec![PathBuf::from("/test/file.txt")], ), ]; let merged = merge_events(events); let path_kinds: std::collections::HashMap<_, _> = merged .iter() .flat_map(|e| e.paths.iter().map(move |p| (p.clone(), e.kind))) .collect(); assert_eq!( path_kinds.get(&PathBuf::from("/test/file.txt")), Some(&FsEventKind::Renamed), "Any+Rename should become Rename" ); } #[test] fn test_merge_other_events_filtered() { // Other/Access events should be filtered out let events = vec![ make_debounced_event(EventKind::Other, vec![PathBuf::from("/test/other.txt")]), make_debounced_event( EventKind::Access(notify::event::AccessKind::Read), vec![PathBuf::from("/test/access.txt")], ), make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/create.txt")], ), ]; let merged = merge_events(events); // Should only have the Create event assert_eq!(merged.len(), 1); assert_eq!(merged[0].kind, FsEventKind::Created); assert!(merged[0].paths.iter().any(|p| p.ends_with("create.txt"))); } #[test] fn test_merge_multiple_different_paths() { let events = vec![ make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/a.txt")], ), make_debounced_event( EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Content)), vec![PathBuf::from("/test/b.txt")], ), make_debounced_event( EventKind::Remove(RemoveKind::File), vec![PathBuf::from("/test/c.txt")], ), ]; let merged = merge_events(events); let path_kinds: std::collections::HashMap<_, _> = merged .iter() .flat_map(|e| e.paths.iter().map(move |p| (p.clone(), e.kind))) .collect(); assert_eq!( path_kinds.get(&PathBuf::from("/test/a.txt")), Some(&FsEventKind::Created) ); assert_eq!( path_kinds.get(&PathBuf::from("/test/b.txt")), Some(&FsEventKind::Modified) ); assert_eq!( path_kinds.get(&PathBuf::from("/test/c.txt")), Some(&FsEventKind::Removed) ); } #[test] fn test_merge_empty_events() { let events: Vec = vec![]; let merged = merge_events(events); assert!(merged.is_empty()); } #[test] fn test_merge_groups_by_kind() { // Multiple files with same kind should be grouped let events = vec![ make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/a.txt")], ), make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/b.txt")], ), make_debounced_event( EventKind::Create(CreateKind::File), vec![PathBuf::from("/test/c.txt")], ), ]; let merged = merge_events(events); // All creates should be grouped into one RawFsEvent let create_events: Vec<_> = merged .iter() .filter(|e| e.kind == FsEventKind::Created) .collect(); assert_eq!(create_events.len(), 1); assert_eq!(create_events[0].paths.len(), 3); } } mod build_globsets_tests { use super::*; #[test] fn test_build_globsets_empty() { let (ignore, include) = build_globsets(&[]); assert!(ignore.is_none()); assert!(include.is_none()); } #[test] fn test_build_globsets_ignore_pattern() { let (ignore, include) = build_globsets(&["*.log".to_string()]); assert!(ignore.is_some()); assert!(include.is_none()); let ignore_set = ignore.unwrap(); assert!(ignore_set.is_match("debug.log")); assert!(ignore_set.is_match("path/to/error.log")); assert!(!ignore_set.is_match("readme.txt")); } #[test] fn test_build_globsets_negation_pattern() { let (ignore, include) = build_globsets(&["!important.log".to_string()]); assert!(ignore.is_none()); assert!(include.is_some()); let include_set = include.unwrap(); assert!(include_set.is_match("important.log")); assert!(include_set.is_match("path/to/important.log")); assert!(!include_set.is_match("other.log")); } #[test] fn test_build_globsets_mixed_patterns() { let (ignore, include) = build_globsets(&[ "*.log".to_string(), "*.tmp".to_string(), "!important.log".to_string(), "!keep.tmp".to_string(), ]); assert!(ignore.is_some()); assert!(include.is_some()); let ignore_set = ignore.unwrap(); let include_set = include.unwrap(); // Ignore patterns assert!(ignore_set.is_match("debug.log")); assert!(ignore_set.is_match("cache.tmp")); // Include patterns (negations) assert!(include_set.is_match("important.log")); assert!(include_set.is_match("keep.tmp")); } #[test] fn test_build_globsets_directory_pattern() { let (ignore, _) = build_globsets(&["target/**".to_string()]); assert!(ignore.is_some()); let ignore_set = ignore.unwrap(); assert!(ignore_set.is_match("target/debug/binary")); assert!(ignore_set.is_match("target/release/lib.so")); assert!(!ignore_set.is_match("src/main.rs")); } #[test] fn test_build_globsets_absolute_pattern() { // Patterns starting with / shouldn't get **/ prepended let (ignore, _) = build_globsets(&["/root_only.txt".to_string()]); assert!(ignore.is_some()); let ignore_set = ignore.unwrap(); // Note: The pattern /root_only.txt matches paths ending with /root_only.txt // This is slightly different from gitignore semantics but works for our use case assert!(ignore_set.is_match("/root_only.txt")); } #[test] fn test_build_globsets_already_prefixed() { // Patterns already starting with **/ shouldn't get double-prefixed let (ignore, _) = build_globsets(&["**/node_modules/**".to_string()]); assert!(ignore.is_some()); let ignore_set = ignore.unwrap(); assert!(ignore_set.is_match("node_modules/package/index.js")); assert!(ignore_set.is_match("frontend/node_modules/lodash/index.js")); } #[test] fn test_build_globsets_complex_patterns() { let (ignore, _) = build_globsets(&[ "*.{log,tmp,bak}".to_string(), "__pycache__/**".to_string(), ".DS_Store".to_string(), ]); assert!(ignore.is_some()); let ignore_set = ignore.unwrap(); assert!(ignore_set.is_match("debug.log")); assert!(ignore_set.is_match("file.tmp")); assert!(ignore_set.is_match("backup.bak")); assert!(ignore_set.is_match("__pycache__/module.pyc")); assert!(ignore_set.is_match(".DS_Store")); assert!(ignore_set.is_match("subdir/.DS_Store")); } } mod config_tests { use super::*; #[test] fn test_config_default() { let config = FsNotifyConfig::default(); assert_eq!(config.debounce_ms, DEBOUNCE_MS); assert!(config.ignore_patterns.is_empty()); } #[test] fn test_config_struct_literal() { let config = FsNotifyConfig { debounce_ms: 250, ignore_patterns: vec!["target/**".to_string()], }; assert_eq!(config.debounce_ms, 250); assert_eq!(config.ignore_patterns, vec!["target/**".to_string()]); } #[test] fn is_git_path_for_watcher_lets_index_lock_through() { assert!(is_git_path_for_watcher(Path::new("/r/.git/index.lock"))); assert!(is_git_path_for_watcher(Path::new("/r/.git/HEAD"))); assert!(!is_git_path_for_watcher(Path::new( "/r/.git/COMMIT_EDITMSG" ))); assert!(!is_git_path_for_watcher(Path::new("/r/src/main.rs"))); } #[test] fn is_sl_path_for_watcher_lets_only_wlock_through() { assert!(is_sl_path_for_watcher(Path::new("/r/.sl/wlock"))); // dirstate is read on demand, never watched — must NOT pass. assert!(!is_sl_path_for_watcher(Path::new("/r/.sl/dirstate"))); assert!(!is_sl_path_for_watcher(Path::new("/r/.sl/store/lock"))); assert!(!is_sl_path_for_watcher(Path::new("/r/src/main.rs"))); } #[test] fn gitignore_cache_is_ignored_handles_sl_like_git() { let mut cache = GitignoreCache::default(); // Only `.sl/wlock` reaches the source; everything else under `.sl` // (notably `dirstate`, read on demand) stays ignored. assert!(!cache.is_ignored(Path::new("/ws/.sl/wlock"), true, true)); assert!(cache.is_ignored(Path::new("/ws/.sl/dirstate"), true, true)); assert!(cache.is_ignored(Path::new("/ws/.sl/store/lock"), true, true)); // With watch_vcs off, even wlock is ignored (mirrors `.git`). assert!(cache.is_ignored(Path::new("/ws/.sl/wlock"), false, true)); // Kill-switch off: the `.sl` arm is skipped, so `.sl/*` is no longer // specially ignored here (it is dropped structurally in the source). assert!(!cache.is_ignored(Path::new("/ws/.sl/dirstate"), true, false)); } #[test] fn test_gitignore_cache_is_ignored_with_watch_vcs() { let mut cache = GitignoreCache::default(); // Without watch_vcs, all git paths are ignored. assert!(cache.is_ignored(Path::new("/workspace/.git/index"), false, true)); assert!(cache.is_ignored(Path::new("/workspace/.git/HEAD"), false, true)); assert!(cache.is_ignored(Path::new("/workspace/.git/objects/123"), false, true)); // With watch_vcs, watched git files are NOT ignored. assert!(!cache.is_ignored(Path::new("/workspace/.git/index"), true, true)); assert!(!cache.is_ignored(Path::new("/workspace/.git/HEAD"), true, true)); assert!(!cache.is_ignored(Path::new("/workspace/.git/refs/heads/main"), true, true)); assert!(!cache.is_ignored(Path::new("/workspace/.git/packed-refs"), true, true)); // Other git paths are still ignored even with watch_vcs. assert!(cache.is_ignored(Path::new("/workspace/.git/objects/123"), true, true)); assert!(cache.is_ignored(Path::new("/workspace/.git/COMMIT_EDITMSG"), true, true)); } } mod select_top_level_watch_dirs_tests { use super::*; use std::fs; use tempfile::TempDir; fn contains_name(dirs: &[PathBuf], name: &str) -> bool { dirs.iter() .any(|d| d.file_name().is_some_and(|n| n == name)) } #[test] fn returns_only_immediate_child_dirs() { let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join("src/utils")).unwrap(); fs::create_dir_all(root.join("tests")).unwrap(); fs::write(root.join("README.md"), "x").unwrap(); let dirs = select_top_level_watch_dirs(root, &None, &None); assert!(contains_name(&dirs, "src"), "src must be watched: {dirs:?}"); assert!( contains_name(&dirs, "tests"), "tests must be watched: {dirs:?}" ); // Depth-2 dirs are reached via the child's recursive watch, not here. assert!( !contains_name(&dirs, "utils"), "nested utils must not be a top-level entry: {dirs:?}" ); // The root is watched non-recursively and never returned here. assert!(!dirs.iter().any(|d| d == root), "root must not be returned"); // Files are covered by the root watch, not watched directly. assert!(!contains_name(&dirs, "README.md")); } #[test] fn excludes_git_directory() { let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join(".git/objects")).unwrap(); fs::create_dir_all(root.join("src")).unwrap(); let dirs = select_top_level_watch_dirs(root, &None, &None); assert!(contains_name(&dirs, "src")); assert!( !contains_name(&dirs, ".git"), ".git is watched separately, not as a recursive child: {dirs:?}" ); } #[test] fn excludes_sl_directory() { // `.sl` is watched separately (non-recursively), never as a // recursive workspace child — same treatment as `.git`. let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join(".sl/store")).unwrap(); fs::create_dir_all(root.join("src")).unwrap(); let dirs = select_top_level_watch_dirs(root, &None, &None); assert!(contains_name(&dirs, "src")); assert!( !contains_name(&dirs, ".sl"), ".sl must not be a recursive child (avoids .sl/store churn): {dirs:?}" ); } #[test] fn excludes_gitignored_target_but_keeps_similar_names() { let temp = TempDir::new().unwrap(); let root = temp.path(); // `.git` is required for WalkBuilder to honor `.gitignore`. fs::create_dir_all(root.join(".git")).unwrap(); fs::write(root.join(".gitignore"), "target/\n").unwrap(); fs::create_dir_all(root.join("src")).unwrap(); fs::create_dir_all(root.join("target/debug")).unwrap(); fs::create_dir_all(root.join("target_data")).unwrap(); let dirs = select_top_level_watch_dirs(root, &None, &None); assert!(contains_name(&dirs, "src"), "src must be watched: {dirs:?}"); // The whole point of the change: a gitignored top-level dir is // never watched, so its churn never reaches the pipeline. assert!( !contains_name(&dirs, "target"), "gitignored target must be excluded: {dirs:?}" ); // A different dir that merely shares a prefix must not be excluded. assert!( contains_name(&dirs, "target_data"), "non-ignored target_data must be watched: {dirs:?}" ); } #[test] fn honors_custom_ignore_patterns() { let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join("src")).unwrap(); fs::create_dir_all(root.join("vendor")).unwrap(); let (ignore, _) = build_globsets(&["**/vendor".to_string()]); let dirs = select_top_level_watch_dirs(root, &ignore, &None); assert!(contains_name(&dirs, "src")); assert!( !contains_name(&dirs, "vendor"), "custom-ignored vendor must be excluded: {dirs:?}" ); } #[test] fn custom_include_overrides_custom_ignore() { let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join("src")).unwrap(); fs::create_dir_all(root.join("vendor")).unwrap(); let (ignore, include) = build_globsets(&["**/vendor".to_string(), "!**/vendor".to_string()]); // Control: ignore alone excludes vendor. let ignored_only = select_top_level_watch_dirs(root, &ignore, &None); assert!( !contains_name(&ignored_only, "vendor"), "control: ignore alone must exclude vendor: {ignored_only:?}" ); // Include overrides the ignore for the same path. let dirs = select_top_level_watch_dirs(root, &ignore, &include); assert!( contains_name(&dirs, "vendor"), "include must override ignore: {dirs:?}" ); assert!(contains_name(&dirs, "src")); } #[test] fn empty_root_returns_no_dirs() { let temp = TempDir::new().unwrap(); let dirs = select_top_level_watch_dirs(temp.path(), &None, &None); assert!(dirs.is_empty(), "empty root has no child dirs: {dirs:?}"); } #[test] fn files_only_returns_no_dirs() { let temp = TempDir::new().unwrap(); let root = temp.path(); fs::write(root.join("a.txt"), "x").unwrap(); fs::write(root.join("b.txt"), "x").unwrap(); let dirs = select_top_level_watch_dirs(root, &None, &None); assert!( dirs.is_empty(), "top-level files are covered by the root watch: {dirs:?}" ); } #[test] fn hidden_non_ignored_dir_is_included() { let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join(".config")).unwrap(); fs::create_dir_all(root.join("src")).unwrap(); let dirs = select_top_level_watch_dirs(root, &None, &None); assert!( contains_name(&dirs, ".config"), "hidden non-ignored dirs are watched: {dirs:?}" ); assert!(contains_name(&dirs, "src")); } #[test] fn watches_children_even_when_root_is_under_a_gitignored_path() { // The user explicitly chose a cwd that an ancestor .gitignore marks // ignored; its children must still be watched. let temp = TempDir::new().unwrap(); let repo = temp.path(); fs::create_dir_all(repo.join(".git")).unwrap(); fs::write(repo.join(".gitignore"), "build/\n").unwrap(); let watch_root = repo.join("build"); fs::create_dir_all(watch_root.join("src")).unwrap(); fs::create_dir_all(watch_root.join("out")).unwrap(); let dirs = select_top_level_watch_dirs(&watch_root, &None, &None); assert!( contains_name(&dirs, "src"), "children of a gitignored root must still be watched: {dirs:?}" ); assert!( contains_name(&dirs, "out"), "children of a gitignored root must still be watched: {dirs:?}" ); } #[cfg(unix)] #[test] fn symlinked_child_dir_is_skipped() { let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join("real")).unwrap(); // A symlinked dir would, if recursively watched, leave the // workspace; it must be skipped. std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap(); let dirs = select_top_level_watch_dirs(root, &None, &None); assert!(contains_name(&dirs, "real"), "real dir watched: {dirs:?}"); assert!( !contains_name(&dirs, "link"), "symlinked child must be skipped: {dirs:?}" ); } #[test] fn excludes_dir_ignored_only_by_git_info_exclude() { // `.git/info/exclude` is honored at the watch level (WalkBuilder) // but NOT by the per-event GitignoreCache — so this exercises the // stronger watch-level coverage specifically. let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join(".git/info")).unwrap(); fs::write(root.join(".git/info/exclude"), "generated/\n").unwrap(); fs::create_dir_all(root.join("src")).unwrap(); fs::create_dir_all(root.join("generated")).unwrap(); let dirs = select_top_level_watch_dirs(root, &None, &None); assert!(contains_name(&dirs, "src"), "src watched: {dirs:?}"); assert!( !contains_name(&dirs, "generated"), ".git/info/exclude'd dir must be excluded at the watch level: {dirs:?}" ); } #[test] fn gitignore_wins_over_custom_include_at_watch_level() { // WalkBuilder never yields a gitignored child, so a negation cannot // re-add a gitignored top-level dir at the watch level. let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join(".git")).unwrap(); fs::write(root.join(".gitignore"), "vendor/\n").unwrap(); fs::create_dir_all(root.join("vendor")).unwrap(); fs::create_dir_all(root.join("src")).unwrap(); let (_, include) = build_globsets(&["!**/vendor".to_string()]); let dirs = select_top_level_watch_dirs(root, &None, &include); assert!(contains_name(&dirs, "src")); assert!( !contains_name(&dirs, "vendor"), "gitignore wins over custom_include at the watch level: {dirs:?}" ); } } mod per_dir_tests { use super::*; use std::fs; use tempfile::TempDir; fn rel(dirs: &[PathBuf], root: &Path) -> Vec { dirs.iter() .map(|d| { d.strip_prefix(root) .unwrap() .to_string_lossy() .replace('\\', "/") }) .collect() } /// The core regression: gitignored dirs nested *below* the top level /// (invisible to the fan-out selector) are pruned at every depth. #[test] fn select_per_dir_prunes_nested_gitignored_dirs() { let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join(".git")).unwrap(); fs::write(root.join(".gitignore"), "node_modules/\ntarget/\n").unwrap(); fs::create_dir_all(root.join("web/src/components")).unwrap(); fs::create_dir_all(root.join("web/node_modules/react/lib")).unwrap(); fs::create_dir_all(root.join("svc/target/debug/deps")).unwrap(); fs::create_dir_all(root.join("svc/src")).unwrap(); let dirs = select_per_dir_watch_dirs(root, &None, &None); let names = rel(&dirs, root); for expected in ["web", "web/src", "web/src/components", "svc", "svc/src"] { assert!( names.contains(&expected.to_string()), "{expected}: {names:?}" ); } assert!( !names.iter().any(|n| n.contains("node_modules")), "nested node_modules must be pruned: {names:?}" ); assert!( !names.iter().any(|n| n.contains("target")), "nested target must be pruned: {names:?}" ); assert!( !names.iter().any(|n| n.contains(".git")), ".git is watched separately: {names:?}" ); } /// Shallow-first ordering means a watch budget sheds the deepest dirs. #[test] fn select_per_dir_orders_shallow_first() { let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join("a/b/c/d")).unwrap(); fs::create_dir_all(root.join("z")).unwrap(); let dirs = select_per_dir_watch_dirs(root, &None, &None); let depths: Vec = dirs.iter().map(|d| d.components().count()).collect(); let mut sorted = depths.clone(); sorted.sort_unstable(); assert_eq!(depths, sorted, "must be shallow-first: {dirs:?}"); } /// Custom glob pruning applies at every depth, like the top-level /// selector's semantics. #[test] fn select_per_dir_applies_custom_globs() { let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join("keep/skipme/deep")).unwrap(); fs::create_dir_all(root.join("keep/sub")).unwrap(); let (ignore, include) = build_globsets(&["skipme".to_string()]); let dirs = select_per_dir_watch_dirs(root, &ignore, &include); let names = rel(&dirs, root); assert!(names.contains(&"keep".to_string())); assert!(names.contains(&"keep/sub".to_string())); assert!( !names.iter().any(|n| n.contains("skipme")), "custom-ignored subtree must be pruned: {names:?}" ); } /// Symlinked dirs are never watched (or followed), so watches can't /// leave the workspace. #[cfg(unix)] #[test] fn select_per_dir_skips_symlinked_dirs() { let temp = TempDir::new().unwrap(); let outside = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join("real")).unwrap(); std::os::unix::fs::symlink(outside.path(), root.join("linked")).unwrap(); let dirs = select_per_dir_watch_dirs(root, &None, &None); let names = rel(&dirs, root); assert!(names.contains(&"real".to_string())); assert!( !names.iter().any(|n| n.contains("linked")), "symlinked dir must not be watched: {names:?}" ); } /// `.git` gets surgical watches: non-recursive `.git` + `refs`, /// recursive `refs/heads` + `refs/tags` — never `objects/`/`modules/`. #[test] fn per_dir_git_watches_are_surgical() { let temp = TempDir::new().unwrap(); let gd = temp.path().join(".git"); for d in [ "objects/ab", "modules/sub/objects/cd", "refs/heads/feature", "refs/tags", "refs/remotes/origin", ] { fs::create_dir_all(gd.join(d)).unwrap(); } let watches = per_dir_git_watches(&gd); let paths: Vec<(String, RecursiveMode)> = watches .iter() .map(|(p, m)| { ( p.strip_prefix(temp.path()) .unwrap() .to_string_lossy() .replace('\\', "/"), *m, ) }) .collect(); assert!(paths.contains(&(".git".into(), RecursiveMode::NonRecursive))); assert!(paths.contains(&(".git/refs".into(), RecursiveMode::NonRecursive))); assert!(paths.contains(&(".git/refs/heads".into(), RecursiveMode::Recursive))); assert!(paths.contains(&(".git/refs/tags".into(), RecursiveMode::Recursive))); assert!( !paths.iter().any(|(p, _)| p.contains("objects") || p.contains("modules") || p.contains("remotes")), "objects/modules/remotes must never be watched: {paths:?}" ); } /// Worktree git dirs (no `refs/`) degrade to just the non-recursive /// dir watch covering their `HEAD`/`index`. #[test] fn per_dir_git_watches_worktree_gitdir() { let temp = TempDir::new().unwrap(); let gd = temp.path().join(".git/worktrees/wt"); fs::create_dir_all(&gd).unwrap(); let watches = per_dir_git_watches(&gd); assert_eq!( watches, vec![(gd.clone(), RecursiveMode::NonRecursive)], "worktree gitdir has no refs/: {watches:?}" ); } #[test] fn scan_updates_classifies_created_dirs_and_files() { let temp = TempDir::new().unwrap(); let dir = temp.path().join("newdir"); let file = temp.path().join("newfile"); fs::create_dir(&dir).unwrap(); fs::write(&file, "x").unwrap(); let mut pruned = Vec::new(); let mut added = Vec::new(); scan_per_dir_updates( FsEventKind::Created, &[dir.clone(), file.clone()], &mut pruned, &mut added, ); assert_eq!(added, vec![dir.clone()], "only dirs become subtree adds"); // Structural event on an existing dir also prunes (re-arm for the // delete+recreate-within-one-debounce case); the file prune // candidate is rejected O(1) by the watcher thread. assert_eq!(pruned, vec![dir, file]); } #[test] fn scan_updates_prunes_removed_paths() { let mut pruned = Vec::new(); let mut added = Vec::new(); scan_per_dir_updates( FsEventKind::Removed, &[PathBuf::from("/gone/dir")], &mut pruned, &mut added, ); assert_eq!(pruned, vec![PathBuf::from("/gone/dir")]); assert!(added.is_empty()); } /// FSEvents can coalesce a subtree removal into `Modified` on the /// vanished parent — state, not kind, must drive the prune. #[test] fn scan_updates_prunes_vanished_dir_on_modified() { let mut pruned = Vec::new(); let mut added = Vec::new(); scan_per_dir_updates( FsEventKind::Modified, &[PathBuf::from("/vanished/pkg")], &mut pruned, &mut added, ); assert_eq!(pruned, vec![PathBuf::from("/vanished/pkg")]); assert!(added.is_empty()); } /// A `Modified` on an existing dir (metadata touch) is an add /// candidate only — no re-arm prune, so the watcher thread's /// `contains` check makes it a no-op. #[test] fn scan_updates_modified_existing_dir_is_add_candidate_only() { let temp = TempDir::new().unwrap(); let dir = temp.path().join("d"); fs::create_dir(&dir).unwrap(); let mut pruned = Vec::new(); let mut added = Vec::new(); scan_per_dir_updates( FsEventKind::Modified, std::slice::from_ref(&dir), &mut pruned, &mut added, ); assert_eq!(added, vec![dir]); assert!(pruned.is_empty(), "no re-arm for non-structural events"); } /// Rename shapes (`From`/`To`/`Both`) classify by on-disk state: the /// vanished old name prunes, the existing new name adds (with re-arm). #[test] fn scan_updates_classifies_renames_by_disk_state() { let temp = TempDir::new().unwrap(); let new_dir = temp.path().join("new"); fs::create_dir(&new_dir).unwrap(); // never created — "moved away" let old_dir = temp.path().join("old"); let mut pruned = Vec::new(); let mut added = Vec::new(); scan_per_dir_updates( FsEventKind::Renamed, &[old_dir.clone(), new_dir.clone()], &mut pruned, &mut added, ); assert_eq!(pruned, vec![old_dir, new_dir.clone()]); assert_eq!(added, vec![new_dir]); } /// Symlinked dir created at runtime must not become a subtree add. #[cfg(unix)] #[test] fn scan_updates_skips_symlinked_dirs() { let temp = TempDir::new().unwrap(); let target = temp.path().join("t"); fs::create_dir(&target).unwrap(); let link = temp.path().join("link"); std::os::unix::fs::symlink(&target, &link).unwrap(); let mut pruned = Vec::new(); let mut added = Vec::new(); scan_per_dir_updates( FsEventKind::Created, std::slice::from_ref(&link), &mut pruned, &mut added, ); assert!(added.is_empty(), "symlink must not be added: {added:?}"); } } mod helper_tests { use super::*; use std::fs; use tempfile::TempDir; #[test] fn is_top_level_child_distinguishes_levels() { let root = Path::new("/work/repo"); assert!(is_top_level_child(Path::new("/work/repo/src"), root)); assert!(is_top_level_child(Path::new("/work/repo/file.rs"), root)); // Grandchildren, the root itself, and outside paths are not. assert!(!is_top_level_child( Path::new("/work/repo/src/main.rs"), root )); assert!(!is_top_level_child(root, root)); assert!(!is_top_level_child(Path::new("/work/other"), root)); } #[test] fn event_triggers_reconcile_only_for_top_level_structural() { let root = Path::new("/r"); let child = [PathBuf::from("/r/pkg")]; let nested = [PathBuf::from("/r/pkg/sub")]; // Structural change to a direct child → reconcile. for kind in [ FsEventKind::Created, FsEventKind::Removed, FsEventKind::Renamed, ] { assert!(event_triggers_reconcile(kind, &child, root), "{kind:?}"); } // A bare modify is not structural. assert!(!event_triggers_reconcile( FsEventKind::Modified, &child, root )); // Structural but deeper than a direct child. assert!(!event_triggers_reconcile( FsEventKind::Created, &nested, root )); // Any top-level child in the batch is enough (drives the // one-reconcile-per-batch coalescing in the callback). let mixed = [PathBuf::from("/r/pkg/sub"), PathBuf::from("/r/newpkg")]; assert!(event_triggers_reconcile(FsEventKind::Created, &mixed, root)); } #[test] fn find_git_dir_discovers_real_repo_and_from_subdir() { // A real repo's `.git` is found from the root and from a subdir. let temp = TempDir::new().unwrap(); git2::Repository::init(temp.path()).unwrap(); let gd = find_git_dir(temp.path()).expect("repo .git found"); assert!(gd.ends_with(".git") && gd.is_dir(), "got {gd:?}"); let sub = temp.path().join("crates/inner"); fs::create_dir_all(&sub).unwrap(); let gd_sub = find_git_dir(&sub).expect(".git found from subdir"); assert!( gd_sub.ends_with(".git") && gd_sub.is_dir(), "got {gd_sub:?}" ); } #[test] fn find_git_dir_none_when_no_repo() { // Hermetic: we create no `.git`, so `find_git_dir` must not return one // inside our tree (an ancestor repo's `.git`, outside it, is fine). let temp = TempDir::new().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); let deep = root.join("no/git/here"); fs::create_dir_all(&deep).unwrap(); let result = find_git_dir(&deep); assert!( result.as_ref().is_none_or(|p| !p.starts_with(&root)), "must not find a .git inside a tree that contains none, got {result:?}" ); } #[test] fn find_git_dir_rejects_bogus_gitlink() { // A planted `.git` file pointing at a non-git dir must NOT be // resolved/watched — git validation rejects it. let external = TempDir::new().unwrap(); let proj = TempDir::new().unwrap(); fs::write( proj.path().join(".git"), format!("gitdir: {}\n", external.path().display()), ) .unwrap(); let resolved = find_git_dir(proj.path()); let external_canon = dunce::canonicalize(external.path()).unwrap(); assert!( resolved.as_deref() != Some(external_canon.as_path()), "bogus gitlink target must not be watched, got {resolved:?}" ); } #[cfg(unix)] #[test] fn find_git_dir_rejects_symlinked_git_to_external_dir() { // A `.git` SYMLINK to an external (non-git) dir must NOT be followed // and watched: the cheap dir branch is gated on a real (non-symlink) // dir, and git validation rejects the target. // stands in for ~/.ssh, /etc let external = TempDir::new().unwrap(); let proj = TempDir::new().unwrap(); std::os::unix::fs::symlink(external.path(), proj.path().join(".git")).unwrap(); let resolved = find_git_dir(proj.path()); let external_canon = dunce::canonicalize(external.path()).unwrap(); assert!( resolved.as_deref() != Some(external_canon.as_path()), "symlinked .git to an external dir must not be watched, got {resolved:?}" ); } #[test] fn find_git_dir_resolves_legitimate_gitlink() { // A `.git` FILE pointing at a real git dir (the worktree / submodule // layout) must resolve to that gitdir. let temp = TempDir::new().unwrap(); let main = temp.path().join("main"); fs::create_dir_all(&main).unwrap(); let real_gitdir = git2::Repository::init(&main).unwrap().path().to_path_buf(); let real_gitdir = dunce::canonicalize(&real_gitdir).unwrap_or(real_gitdir); let linked = temp.path().join("linked"); fs::create_dir_all(&linked).unwrap(); fs::write( linked.join(".git"), format!("gitdir: {}\n", real_gitdir.display()), ) .unwrap(); let resolved = find_git_dir(&linked).expect("legit gitlink must resolve"); assert_eq!( resolved, real_gitdir, "gitlink must resolve to the real gitdir" ); } #[test] fn find_sl_dir_discovers_real_repo_and_from_subdir() { let temp = TempDir::new().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); fs::create_dir(root.join(".sl")).unwrap(); let sd = find_sl_dir(&root).expect("repo .sl found"); assert!( sd.file_name().is_some_and(|n| n == ".sl") && sd.is_dir(), "got {sd:?}" ); let sub = root.join("crates/inner"); fs::create_dir_all(&sub).unwrap(); let sd_sub = find_sl_dir(&sub).expect(".sl found from subdir"); assert_eq!(sd_sub, sd, "subdir walk must find the ancestor .sl"); } #[test] fn find_sl_dir_none_when_no_repo() { // Hermetic: no `.sl` created, so none must be found inside our tree. let temp = TempDir::new().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); let deep = root.join("no/sl/here"); fs::create_dir_all(&deep).unwrap(); assert!( find_sl_dir(&deep).is_none_or(|p| !p.starts_with(&root)), "must not find a .sl in a tree that contains none" ); } #[cfg(unix)] #[test] fn find_sl_dir_rejects_symlinked_sl_to_external_dir() { // A `.sl` SYMLINK to an external dir must not be followed/watched: // the dir branch is gated on a real (non-symlink) dir. let external = TempDir::new().unwrap(); let proj = TempDir::new().unwrap(); std::os::unix::fs::symlink(external.path(), proj.path().join(".sl")).unwrap(); let resolved = find_sl_dir(proj.path()); let external_canon = dunce::canonicalize(external.path()).unwrap(); assert!( resolved.as_deref() != Some(external_canon.as_path()), "symlinked .sl to an external dir must not be watched, got {resolved:?}" ); } #[test] fn should_watch_separate_vcs_dir_cases() { let watch = Path::new("/repo/crates/codegen"); let internal = Path::new("/repo/crates/codegen/.sl"); let external = Path::new("/repo/.sl"); // Fan-out: the root is non-recursive, so always watch separately. assert!(should_watch_separate_vcs_dir(true, internal, watch)); assert!(should_watch_separate_vcs_dir(true, external, watch)); // Recursive root: an internal dir is already covered — must NOT be // re-watched (the double-watch the design warns against)... assert!(!should_watch_separate_vcs_dir(false, internal, watch)); // ...but an external ancestor (subdir cwd) must still be watched, or // suppression silently breaks. assert!(should_watch_separate_vcs_dir(false, external, watch)); } #[test] fn external_ancestor_sl_arms_in_recursive_root_mode() { // Subdir cwd whose `.sl` lives in an ancestor *outside* watch_path // (e.g. `kigi` run in `crates/codegen`): the production guard must // still attach the watch under a recursive root (fanout=false). let temp = TempDir::new().unwrap(); let repo = dunce::canonicalize(temp.path()).unwrap(); fs::create_dir(repo.join(".sl")).unwrap(); let watch_path = repo.join("crates/codegen"); fs::create_dir_all(&watch_path).unwrap(); let sd = find_sl_dir(&watch_path).expect("ancestor .sl discovered"); assert!(should_watch_separate_vcs_dir(false, &sd, &watch_path)); } #[test] #[serial_test::serial] fn sapling_enabled_respects_kill_switch() { // Clear the env var even if an assert panics mid-test. struct Restore; impl Drop for Restore { fn drop(&mut self) { // Safety: serialized test; no concurrent env access. unsafe { std::env::remove_var("KIGI_FSNOTIFY_SAPLING") }; } } let _restore = Restore; unsafe { std::env::remove_var("KIGI_FSNOTIFY_SAPLING") }; assert!(sapling_enabled(), "default (unset) is enabled"); for off in ["0", "false"] { unsafe { std::env::set_var("KIGI_FSNOTIFY_SAPLING", off) }; assert!(!sapling_enabled(), "{off:?} must disable Sapling"); } unsafe { std::env::set_var("KIGI_FSNOTIFY_SAPLING", "1") }; assert!(sapling_enabled(), "any other value stays enabled"); } #[test] fn select_capped_boundary_is_inclusive() { let temp = TempDir::new().unwrap(); let root = temp.path(); for i in 0..3 { fs::create_dir_all(root.join(format!("d{i}"))).unwrap(); } // Exactly `cap` non-ignored dirs must fan out (inclusive `<=`); this // pins the edge so flipping the comparison to `<` would fail. assert_eq!( select_top_level_watch_dirs_capped(root, &None, &None, 3).map(|v| v.len()), Some(3), "count == cap must fan out" ); // One past the cap falls back. assert!( select_top_level_watch_dirs_capped(root, &None, &None, 2).is_none(), "count > cap must fall back" ); // And comfortably within the cap. assert_eq!( select_top_level_watch_dirs_capped(root, &None, &None, 4).map(|v| v.len()), Some(3) ); } #[test] fn select_capped_does_not_count_ignored_toward_cap() { // Gitignored top-level dirs must not push a repo over the cap. let temp = TempDir::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join(".git")).unwrap(); fs::write(root.join(".gitignore"), "ignored_*/\n").unwrap(); fs::create_dir_all(root.join("real_0")).unwrap(); fs::create_dir_all(root.join("real_1")).unwrap(); for i in 0..5 { fs::create_dir_all(root.join(format!("ignored_{i}"))).unwrap(); } // 7 total dirs, 5 ignored: with a cap of 2 the 2 non-ignored fit. let result = select_top_level_watch_dirs_capped(root, &None, &None, 2); assert_eq!( result.map(|v| v.len()), Some(2), "ignored dirs must not count toward the fan-out cap" ); } #[test] fn diff_watches_add_remove_noop_combined() { let p = |s: &str| PathBuf::from(s); let set = |items: &[&str]| items.iter().map(|s| p(s)).collect::>(); let sorted = |mut v: Vec| { v.sort(); v }; // Add only. let (add, rem) = diff_watches(&set(&["/a", "/b"]), &set(&[])); assert_eq!(sorted(add), vec![p("/a"), p("/b")]); assert!(rem.is_empty()); // Remove only. let (add, rem) = diff_watches(&set(&[]), &set(&["/a"])); assert!(add.is_empty()); assert_eq!(rem, vec![p("/a")]); // No-op. let (add, rem) = diff_watches(&set(&["/a"]), &set(&["/a"])); assert!(add.is_empty() && rem.is_empty()); // Combined. let (add, rem) = diff_watches(&set(&["/a", "/b"]), &set(&["/b", "/c"])); assert_eq!(sorted(add), vec![p("/a")]); assert_eq!(rem, vec![p("/c")]); } } }