Port of upstream grok-build 0.2.113/0.2.114 fixes, extended to Kigi's startup order. - kigi-tty-utils runtime module: worker-cap policy (8 max) — tokio's per-core default pins 100+ thread slots on many-core shared hosts against RLIMIT_NPROC / pids.max, and later spawns die with EAGAIN. - kigi-bin builds the main runtime with capped workers and reports an out-of-threads host with a diagnostic + exit(1) instead of a panic. - The pre-runtime optimization threads (HTTP warmup, early model prefetch) spawn recoverably and degrade — their panic would have preempted the runtime's own diagnostic. - The per-session actor thread and its runtime report spawn/build failure through the existing acp::Error / AgentBuildError boundaries (new RuntimeBuild variant) instead of panicking the agent host. - fsnotify's watcher thread spawn maps into FsNotifyError::WatcherStart so callers degrade instead of crashing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
3899 lines
155 KiB
Rust
3899 lines
155 KiB
Rust
//! 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<PathBuf>,
|
||
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<FsEventKind> {
|
||
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<String>,
|
||
}
|
||
|
||
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::<usize>().ok())
|
||
.filter(|&n| n > 0)
|
||
.unwrap_or(DEFAULT_MAX_WATCHES)
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct GitignoreCache {
|
||
cache: HashMap<PathBuf, (SystemTime, Gitignore)>,
|
||
}
|
||
|
||
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<Item = DebouncedEvent>) -> Vec<RawFsEvent> {
|
||
let mut by_path: HashMap<PathBuf, FsEventKind> = 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<RawFsEvent> = 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<FsEventKind, Vec<PathBuf>> = HashMap::new();
|
||
for (path, kind) in by_path {
|
||
result.entry(kind).or_default().push(path);
|
||
}
|
||
|
||
let mut merged: Vec<RawFsEvent> = 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<PathBuf>,
|
||
added: Vec<PathBuf>,
|
||
},
|
||
/// Stop the watcher thread.
|
||
Shutdown,
|
||
}
|
||
|
||
pub(crate) struct FsNotifyHandle {
|
||
cmd_tx: Option<std::sync::mpsc::Sender<WatchCommand>>,
|
||
thread: Option<std::thread::JoinHandle<()>>,
|
||
/// Live OS-watch count (workspace + VCS), for stats/benchmarks.
|
||
watch_count: Arc<AtomicUsize>,
|
||
}
|
||
|
||
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<GlobSet>, Option<GlobSet>) {
|
||
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<PathBuf>,
|
||
added: &mut Vec<PathBuf>,
|
||
) {
|
||
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<PathBuf> {
|
||
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<PathBuf> {
|
||
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<GlobSet>,
|
||
custom_include: &Option<GlobSet>,
|
||
) -> 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<usize>) -> 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<GlobSet>,
|
||
custom_include: &Option<GlobSet>,
|
||
) -> Vec<PathBuf> {
|
||
// `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<GlobSet>,
|
||
custom_include: &Option<GlobSet>,
|
||
max: usize,
|
||
) -> Option<Vec<PathBuf>> {
|
||
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<GlobSet>,
|
||
custom_include: &Option<GlobSet>,
|
||
) -> 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<GlobSet>,
|
||
custom_include: &Option<GlobSet>,
|
||
) -> Vec<PathBuf> {
|
||
let mut dirs: Vec<PathBuf> = 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/<n>`) 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<PathBuf>,
|
||
live: &HashSet<PathBuf>,
|
||
) -> (Vec<PathBuf>, Vec<PathBuf>) {
|
||
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<notify::RecommendedWatcher, NoCache>,
|
||
watched: &mut HashSet<PathBuf>,
|
||
root: &Path,
|
||
custom_ignore: &Option<GlobSet>,
|
||
custom_include: &Option<GlobSet>,
|
||
) {
|
||
let desired: HashSet<PathBuf> =
|
||
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<notify::RecommendedWatcher, NoCache>,
|
||
watched: &mut HashSet<PathBuf>,
|
||
pending: &mut std::collections::VecDeque<PathBuf>,
|
||
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<notify::RecommendedWatcher, NoCache>,
|
||
watched: &mut HashSet<PathBuf>,
|
||
subtree_root: &Path,
|
||
custom_ignore: &Option<GlobSet>,
|
||
custom_include: &Option<GlobSet>,
|
||
budget: usize,
|
||
tx: &mpsc::UnboundedSender<RawFsEvent>,
|
||
) {
|
||
// 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<PathBuf> = Vec::new();
|
||
let flush = |paths: &mut Vec<PathBuf>| {
|
||
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<notify::RecommendedWatcher, NoCache>,
|
||
watched: &mut HashSet<PathBuf>,
|
||
subtree_root: &Path,
|
||
) {
|
||
let stale: Vec<PathBuf> = 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<RawFsEvent>, 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<RawFsEvent>, 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::<Result<(), Box<dyn std::error::Error + Send + Sync>>>();
|
||
|
||
// 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::<WatchCommand>();
|
||
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<PathBuf> = Vec::new();
|
||
let mut added: Vec<PathBuf> = 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<PathBuf> = HashSet::new();
|
||
let mut pending_dirs: std::collections::VecDeque<PathBuf> =
|
||
std::collections::VecDeque::new();
|
||
if let Some(dirs) = initial {
|
||
let sync_head: Vec<PathBuf> = if per_dir {
|
||
let (head, tail): (Vec<PathBuf>, Vec<PathBuf>) = 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<notify::RecommendedWatcher, NoCache>,
|
||
watched_dirs: &mut HashSet<PathBuf>|
|
||
-> 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<RawFsEvent>,
|
||
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<RawFsEvent>,
|
||
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<RawFsEvent>,
|
||
max_wait: Duration,
|
||
quiet_period: Duration,
|
||
) -> Vec<RawFsEvent> {
|
||
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<RawFsEvent>,
|
||
) -> Vec<RawFsEvent> {
|
||
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<PathBuf>) -> 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<DebouncedEvent> = 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<String> {
|
||
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<usize> = 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::<HashSet<_>>();
|
||
let sorted = |mut v: Vec<PathBuf>| {
|
||
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")]);
|
||
}
|
||
}
|
||
}
|