Skip to main content

aft/
watcher_filter.rs

1use std::collections::BTreeSet;
2use std::path::{Component, Path, PathBuf};
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::sync::{mpsc, Arc, RwLock};
5use std::thread::{self, JoinHandle};
6use std::time::{Duration, Instant};
7
8use crossbeam_channel::{Receiver, SendTimeoutError, Sender};
9use ignore::gitignore::Gitignore;
10
11pub type SharedGitignore = Arc<RwLock<Option<Arc<Gitignore>>>>;
12
13pub const WATCHER_FLUSH_WINDOW: Duration = Duration::from_millis(250);
14pub const WATCHER_MAX_BATCH_PATHS: usize = 1024;
15pub const WATCHER_DISPATCH_CHANNEL_CAPACITY: usize = 1024;
16const ROOT_DELETED_CHECK_INTERVAL: Duration = Duration::from_millis(250);
17const GITIGNORE_REBUILD_POLL_INTERVAL: Duration = Duration::from_millis(10);
18const DISPATCH_SEND_POLL_INTERVAL: Duration = Duration::from_millis(50);
19
20#[derive(Debug, Clone)]
21pub struct WatcherFilterConfig {
22    pub project_root: PathBuf,
23    pub git_common_dir: Option<PathBuf>,
24}
25
26impl WatcherFilterConfig {
27    pub fn new(project_root: PathBuf, git_common_dir: Option<PathBuf>) -> Self {
28        Self {
29            project_root,
30            git_common_dir,
31        }
32    }
33
34    fn git_info_exclude_path(&self) -> PathBuf {
35        self.git_common_dir
36            .clone()
37            .unwrap_or_else(|| self.project_root.join(".git"))
38            .join("info")
39            .join("exclude")
40    }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum WatcherDispatchEvent {
45    Paths(Vec<PathBuf>),
46    RescanRequired,
47    IgnoreRulesChanged { path: PathBuf },
48    RootDeleted,
49    Error(String),
50}
51
52pub struct WatcherThreadHandle {
53    shutdown: Arc<AtomicBool>,
54    join: Option<JoinHandle<()>>,
55}
56
57/// Result of a bounded watcher-thread join.
58pub enum WatcherJoinOutcome {
59    Joined,
60    TimedOut(JoinHandle<()>),
61}
62
63impl WatcherThreadHandle {
64    pub fn new(shutdown: Arc<AtomicBool>, join: JoinHandle<()>) -> Self {
65        Self {
66            shutdown,
67            join: Some(join),
68        }
69    }
70
71    pub fn request_shutdown(&self) {
72        self.shutdown.store(true, Ordering::SeqCst);
73    }
74
75    pub fn is_finished(&self) -> bool {
76        self.join.as_ref().is_none_or(|join| join.is_finished())
77    }
78
79    pub fn shutdown_and_join(mut self) {
80        self.request_shutdown();
81        if let Some(join) = self.join.take() {
82            let _ = join.join();
83        }
84    }
85
86    /// Request shutdown and wait only up to `timeout` for the watcher thread.
87    /// The caller owns the still-live join handle on timeout and can monitor it
88    /// without blocking an executor or transport loop.
89    pub fn shutdown_and_join_timeout(mut self, timeout: Duration) -> WatcherJoinOutcome {
90        self.request_shutdown();
91        let Some(join) = self.join.take() else {
92            return WatcherJoinOutcome::Joined;
93        };
94        let deadline = Instant::now() + timeout;
95        while !join.is_finished() && Instant::now() < deadline {
96            thread::sleep(Duration::from_millis(10));
97        }
98        if join.is_finished() {
99            let _ = join.join();
100            WatcherJoinOutcome::Joined
101        } else {
102            WatcherJoinOutcome::TimedOut(join)
103        }
104    }
105}
106
107impl Drop for WatcherThreadHandle {
108    fn drop(&mut self) {
109        self.request_shutdown();
110    }
111}
112
113pub fn watcher_dispatch_channel() -> (Sender<WatcherDispatchEvent>, Receiver<WatcherDispatchEvent>)
114{
115    crossbeam_channel::bounded(WATCHER_DISPATCH_CHANNEL_CAPACITY)
116}
117
118/// Decide whether a `notify::Event` represents a real content change worth
119/// invalidating cached state for.
120pub fn watcher_event_invalidates(kind: &notify::EventKind) -> bool {
121    use notify::event::{MetadataKind, ModifyKind};
122    use notify::EventKind;
123    match kind {
124        EventKind::Create(_) | EventKind::Remove(_) => true,
125        EventKind::Modify(ModifyKind::Metadata(meta)) => !matches!(
126            meta,
127            MetadataKind::AccessTime
128                | MetadataKind::Permissions
129                | MetadataKind::Ownership
130                | MetadataKind::Extended
131        ),
132        EventKind::Modify(_) => true,
133        _ => false,
134    }
135}
136
137pub fn watcher_path_is_infra_skip(path: &Path) -> bool {
138    path.components().any(|c| {
139        matches!(c, Component::Normal(name) if matches!(
140            name.to_str().unwrap_or(""),
141            ".git" | ".opencode" | ".alfonso" | ".gsd" | "node_modules" | "target"
142        ))
143    })
144}
145
146/// High-churn ignored directories that can be dropped from the raw event stream
147/// before paying for a `realpath` canonicalization.
148///
149/// A build writes hundreds of thousands of files under `target/` (or
150/// `node_modules/` for JS installs); FSEvents delivers every one to AFT, and
151/// canonicalizing each just to drop it later in the filter pegs the
152/// single-threaded watcher loop. This is a pure path-component scan (no syscall),
153/// so the flood is rejected almost for free.
154///
155/// This deliberately omits `.git`: `.git/info/exclude` changes the corpus ignore
156/// set, and dropping `.git` here would hide them from the ignore-relevance check
157/// in the full filter. `.git` churn is small next to `target/`, so it stays on
158/// the canonicalizing path.
159fn watcher_path_is_high_churn_infra(path: &Path) -> bool {
160    path.components().any(|c| {
161        matches!(c, Component::Normal(name) if matches!(
162            name.to_str().unwrap_or(""),
163            ".opencode" | ".alfonso" | ".gsd" | "node_modules" | "target"
164        ))
165    })
166}
167
168fn watcher_path_is_ignore_file(path: &Path) -> bool {
169    path.file_name()
170        .map(|n| n == ".gitignore" || n == ".aftignore")
171        .unwrap_or(false)
172}
173
174fn watcher_same_path(path: &Path, target: &Path) -> bool {
175    if path == target {
176        return true;
177    }
178
179    std::fs::canonicalize(target)
180        .map(|target| path == target)
181        .unwrap_or(false)
182}
183
184fn watcher_path_is_git_info_exclude(config: &WatcherFilterConfig, path: &Path) -> bool {
185    watcher_same_path(path, &config.git_info_exclude_path())
186}
187
188fn watcher_path_is_global_gitignore(path: &Path) -> bool {
189    ignore::gitignore::gitconfig_excludes_path()
190        .as_deref()
191        .is_some_and(|global_ignore| watcher_same_path(path, global_ignore))
192}
193
194fn watcher_path_can_change_corpus_ignore(config: &WatcherFilterConfig, path: &Path) -> bool {
195    if watcher_path_is_global_gitignore(path) {
196        return true;
197    }
198    if watcher_path_is_git_info_exclude(config, path) {
199        return true;
200    }
201    if !path.starts_with(&config.project_root) {
202        return false;
203    }
204
205    watcher_path_is_ignore_file(path) && !watcher_path_is_infra_skip(path)
206}
207
208pub fn canonicalize_watcher_path(path: PathBuf) -> PathBuf {
209    if let Ok(canonical) = std::fs::canonicalize(&path) {
210        return canonical;
211    }
212
213    let parent = path.parent().map(Path::to_path_buf);
214    let file_name = path.file_name().map(std::ffi::OsStr::to_os_string);
215    match (parent, file_name) {
216        (Some(parent), Some(file_name)) => std::fs::canonicalize(parent)
217            .map(|canonical_parent| canonical_parent.join(file_name))
218            .unwrap_or(path),
219        _ => path,
220    }
221}
222
223fn watcher_path_is_ignored_by_matcher(matcher: &SharedGitignore, path: &Path) -> bool {
224    if watcher_path_is_infra_skip(path) {
225        return true;
226    }
227
228    let guard = matcher
229        .read()
230        .unwrap_or_else(|poisoned| poisoned.into_inner());
231    if let Some(matcher) = guard.as_ref() {
232        if path.starts_with(matcher.path()) {
233            let is_dir = path.is_dir();
234            return matcher
235                .matched_path_or_any_parents(path, is_dir)
236                .is_ignore();
237        }
238    }
239
240    false
241}
242
243#[derive(Debug, Default, Clone, PartialEq, Eq)]
244pub struct FilteredWatcherPaths {
245    pub changed: BTreeSet<PathBuf>,
246    pub ignore_file_changed: bool,
247}
248
249fn filter_canonical_paths(
250    config: &WatcherFilterConfig,
251    matcher: &SharedGitignore,
252    raw_paths: BTreeSet<PathBuf>,
253) -> FilteredWatcherPaths {
254    let ignore_file_changed = raw_paths
255        .iter()
256        .any(|path| watcher_path_can_change_corpus_ignore(config, path));
257
258    let changed = raw_paths
259        .into_iter()
260        .filter(|path| {
261            if watcher_path_is_infra_skip(path) {
262                return false;
263            }
264
265            if watcher_path_is_global_gitignore(path)
266                || watcher_path_is_git_info_exclude(config, path)
267            {
268                return false;
269            }
270
271            if watcher_path_is_ignored_by_matcher(matcher, path) {
272                return false;
273            }
274            true
275        })
276        .collect();
277
278    FilteredWatcherPaths {
279        changed,
280        ignore_file_changed,
281    }
282}
283
284pub fn filter_watcher_raw_paths_for_test<I>(
285    config: &WatcherFilterConfig,
286    matcher: &SharedGitignore,
287    raw_paths: I,
288) -> FilteredWatcherPaths
289where
290    I: IntoIterator<Item = PathBuf>,
291{
292    let raw_paths = raw_paths
293        .into_iter()
294        .map(canonicalize_watcher_path)
295        .collect::<BTreeSet<_>>();
296    filter_canonical_paths(config, matcher, raw_paths)
297}
298
299pub fn run_watcher_thread<W, E, F>(
300    config: WatcherFilterConfig,
301    extra_watch_paths: Vec<PathBuf>,
302    matcher: SharedGitignore,
303    matcher_generation: Arc<AtomicU64>,
304    dispatch_tx: Sender<WatcherDispatchEvent>,
305    shutdown: Arc<AtomicBool>,
306    attach: F,
307) where
308    W: Send + 'static,
309    E: std::fmt::Display,
310    F: FnOnce(PathBuf, Vec<PathBuf>, mpsc::Sender<notify::Result<notify::Event>>) -> Result<W, E>,
311{
312    let (raw_tx, raw_rx) = mpsc::channel();
313    let root_path = config.project_root.clone();
314    match attach(root_path.clone(), extra_watch_paths, raw_tx) {
315        Ok(_watcher) => {
316            if shutdown.load(Ordering::SeqCst) {
317                return;
318            }
319            crate::slog_info!("watcher started: {}", root_path.display());
320            let mut filter = WatcherFilterThread::new(
321                config,
322                matcher,
323                matcher_generation,
324                dispatch_tx,
325                shutdown,
326            );
327            filter.run(raw_rx);
328        }
329        Err(error) => {
330            if !shutdown.load(Ordering::SeqCst) {
331                log::debug!(
332                    "watcher init failed: {} — callers will work with stale data",
333                    error
334                );
335                let _ = dispatch_tx.send(WatcherDispatchEvent::Error(format!(
336                    "watcher init failed: {error}"
337                )));
338            }
339        }
340    }
341}
342
343struct WatcherFilterThread {
344    config: WatcherFilterConfig,
345    matcher: SharedGitignore,
346    matcher_generation: Arc<AtomicU64>,
347    dispatch_tx: Sender<WatcherDispatchEvent>,
348    shutdown: Arc<AtomicBool>,
349    raw_paths: BTreeSet<PathBuf>,
350    flush_deadline: Option<Instant>,
351}
352
353impl WatcherFilterThread {
354    fn new(
355        config: WatcherFilterConfig,
356        matcher: SharedGitignore,
357        matcher_generation: Arc<AtomicU64>,
358        dispatch_tx: Sender<WatcherDispatchEvent>,
359        shutdown: Arc<AtomicBool>,
360    ) -> Self {
361        Self {
362            config,
363            matcher,
364            matcher_generation,
365            dispatch_tx,
366            shutdown,
367            raw_paths: BTreeSet::new(),
368            flush_deadline: None,
369        }
370    }
371
372    fn run(&mut self, raw_rx: mpsc::Receiver<notify::Result<notify::Event>>) {
373        loop {
374            if self.shutdown.load(Ordering::SeqCst) {
375                self.flush_pending();
376                return;
377            }
378            if self.project_root_was_deleted() {
379                self.raw_paths.clear();
380                let _ = self.send_dispatch(WatcherDispatchEvent::RootDeleted);
381                return;
382            }
383            if self.flush_deadline_reached() {
384                if !self.flush_pending() {
385                    return;
386                }
387                continue;
388            }
389
390            match raw_rx.recv_timeout(self.next_recv_timeout()) {
391                Ok(Ok(event)) => {
392                    if event.need_rescan() {
393                        self.raw_paths.clear();
394                        self.flush_deadline = None;
395                        if !self.send_dispatch(WatcherDispatchEvent::RescanRequired) {
396                            return;
397                        }
398                        continue;
399                    }
400                    if watcher_event_invalidates(&event.kind) && !self.push_raw_paths(event.paths) {
401                        return;
402                    }
403                }
404                Ok(Err(error)) => {
405                    let _ = self.send_dispatch(WatcherDispatchEvent::Error(error.to_string()));
406                    return;
407                }
408                Err(mpsc::RecvTimeoutError::Timeout) => {
409                    if !self.flush_pending() {
410                        return;
411                    }
412                }
413                Err(mpsc::RecvTimeoutError::Disconnected) => {
414                    if !self.shutdown.load(Ordering::SeqCst) {
415                        let _ = self.send_dispatch(WatcherDispatchEvent::Error(
416                            "watcher channel disconnected".to_string(),
417                        ));
418                    }
419                    return;
420                }
421            }
422        }
423    }
424
425    fn project_root_was_deleted(&self) -> bool {
426        !self.config.project_root.exists()
427    }
428
429    fn push_raw_paths(&mut self, paths: Vec<PathBuf>) -> bool {
430        for path in paths {
431            // Drop high-churn ignored dirs (target/, node_modules/, agent infra)
432            // on the RAW path before canonicalizing. `canonicalize_watcher_path`
433            // is a realpath syscall; a build floods FSEvents with hundreds of
434            // thousands of target/ paths, and paying a syscall per path only to
435            // discard them later pegged this single watcher thread. The full
436            // filter still drops these (watcher_path_is_infra_skip), so this is a
437            // pure perf short-circuit with no behavior change.
438            if watcher_path_is_high_churn_infra(&path) {
439                continue;
440            }
441            // Canonicalize at intake so the set keys (and downstream consumers)
442            // see normalized paths — this is what collapses macOS /var ->
443            // /private/var aliasing and matches the callgraph/semantic/search
444            // cache keys. Same-file repeats within the window still dedup here;
445            // the high-churn flood is already dropped above, before this syscall.
446            self.raw_paths.insert(canonicalize_watcher_path(path));
447        }
448        if !self.raw_paths.is_empty() && self.flush_deadline.is_none() {
449            self.flush_deadline = Some(Instant::now() + WATCHER_FLUSH_WINDOW);
450        }
451        if self.raw_paths.len() >= WATCHER_MAX_BATCH_PATHS {
452            return self.flush_pending();
453        }
454        true
455    }
456
457    fn next_recv_timeout(&self) -> Duration {
458        let root_check = ROOT_DELETED_CHECK_INTERVAL;
459        match self.flush_deadline {
460            Some(deadline) => deadline
461                .saturating_duration_since(Instant::now())
462                .min(root_check),
463            None => root_check,
464        }
465    }
466
467    fn flush_deadline_reached(&self) -> bool {
468        self.flush_deadline
469            .is_some_and(|deadline| Instant::now() >= deadline)
470    }
471
472    fn flush_pending(&mut self) -> bool {
473        if self.raw_paths.is_empty() {
474            self.flush_deadline = None;
475            return true;
476        }
477
478        let raw_paths = std::mem::take(&mut self.raw_paths);
479        self.flush_deadline = None;
480        let ignore_path = raw_paths
481            .iter()
482            .find(|path| watcher_path_can_change_corpus_ignore(&self.config, path))
483            .cloned();
484        let ignore_file_changed = ignore_path.is_some();
485        if let Some(path) = ignore_path {
486            let observed_generation = self.matcher_generation.load(Ordering::SeqCst);
487            if !self.send_dispatch(WatcherDispatchEvent::IgnoreRulesChanged { path }) {
488                return false;
489            }
490            if !self.wait_for_gitignore_rebuild(observed_generation) {
491                return false;
492            }
493        }
494
495        let filtered = filter_canonical_paths(&self.config, &self.matcher, raw_paths);
496        debug_assert_eq!(filtered.ignore_file_changed, ignore_file_changed);
497        if filtered.changed.is_empty() {
498            return true;
499        }
500        self.send_dispatch(WatcherDispatchEvent::Paths(
501            filtered.changed.into_iter().collect(),
502        ))
503    }
504
505    fn wait_for_gitignore_rebuild(&self, observed_generation: u64) -> bool {
506        while !self.shutdown.load(Ordering::SeqCst)
507            && self.matcher_generation.load(Ordering::SeqCst) == observed_generation
508        {
509            if self.project_root_was_deleted() {
510                let _ = self.send_dispatch(WatcherDispatchEvent::RootDeleted);
511                return false;
512            }
513            thread::sleep(GITIGNORE_REBUILD_POLL_INTERVAL);
514        }
515        !self.shutdown.load(Ordering::SeqCst)
516    }
517
518    fn send_dispatch(&self, event: WatcherDispatchEvent) -> bool {
519        let mut event = event;
520        loop {
521            match self
522                .dispatch_tx
523                .send_timeout(event, DISPATCH_SEND_POLL_INTERVAL)
524            {
525                Ok(()) => return true,
526                Err(SendTimeoutError::Timeout(returned)) => {
527                    if self.shutdown.load(Ordering::SeqCst) {
528                        return false;
529                    }
530                    event = returned;
531                }
532                Err(SendTimeoutError::Disconnected(_)) => return false,
533            }
534        }
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use ignore::gitignore::GitignoreBuilder;
542    use notify::event::{
543        AccessKind, AccessMode, CreateKind, DataChange, Flag, MetadataKind, ModifyKind,
544    };
545    use notify::EventKind;
546    use tempfile::TempDir;
547
548    fn shared_matcher(root: &Path) -> SharedGitignore {
549        let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
550        let mut builder = GitignoreBuilder::new(&root);
551        let ignore = root.join(".gitignore");
552        if ignore.exists() {
553            if let Some(error) = builder.add(&ignore) {
554                panic!("gitignore parse error: {error}");
555            }
556        }
557        let matcher = builder.build().unwrap();
558        let matcher = (matcher.num_ignores() > 0).then(|| Arc::new(matcher));
559        Arc::new(RwLock::new(matcher))
560    }
561
562    #[test]
563    fn event_kind_filter_accepts_content_changes_only() {
564        assert!(watcher_event_invalidates(&EventKind::Create(
565            CreateKind::File
566        )));
567        assert!(watcher_event_invalidates(&EventKind::Modify(
568            ModifyKind::Data(DataChange::Content)
569        )));
570        assert!(watcher_event_invalidates(&EventKind::Modify(
571            ModifyKind::Metadata(MetadataKind::WriteTime)
572        )));
573        assert!(!watcher_event_invalidates(&EventKind::Modify(
574            ModifyKind::Metadata(MetadataKind::AccessTime)
575        )));
576        assert!(!watcher_event_invalidates(&EventKind::Modify(
577            ModifyKind::Metadata(MetadataKind::Permissions)
578        )));
579        assert!(!watcher_event_invalidates(&EventKind::Access(
580            AccessKind::Open(AccessMode::Read)
581        )));
582        assert!(!watcher_event_invalidates(&EventKind::Other));
583    }
584
585    #[test]
586    fn high_churn_infra_skip_drops_build_dirs_but_keeps_git_and_source() {
587        // target/ and node_modules/ are dropped on the raw path before the
588        // realpath syscall — this is the build-flood short-circuit.
589        assert!(watcher_path_is_high_churn_infra(Path::new(
590            "/proj/target/debug/deps/foo.o"
591        )));
592        assert!(watcher_path_is_high_churn_infra(Path::new(
593            "/proj/node_modules/.bin/x"
594        )));
595        assert!(watcher_path_is_high_churn_infra(Path::new(
596            "/proj/.alfonso/notes/x"
597        )));
598        // .git is deliberately NOT high-churn-skipped: .git/info/exclude must
599        // still reach the ignore-relevance check.
600        assert!(!watcher_path_is_high_churn_infra(Path::new(
601            "/proj/.git/info/exclude"
602        )));
603        // Source files always pass through to canonicalization + filtering.
604        assert!(!watcher_path_is_high_churn_infra(Path::new(
605            "/proj/src/main.rs"
606        )));
607        // The full filter still drops .git (and everything high-churn does).
608        assert!(watcher_path_is_infra_skip(Path::new("/proj/.git/index")));
609    }
610
611    #[test]
612    fn rescan_event_dispatches_control_and_supersedes_pending_paths() {
613        let tmp = TempDir::new().unwrap();
614        let root = std::fs::canonicalize(tmp.path()).unwrap();
615        let pending = root.join("pending.rs");
616        std::fs::write(&pending, "fn main() {}\n").unwrap();
617        let matcher = Arc::new(RwLock::new(None));
618        let generation = Arc::new(AtomicU64::new(0));
619        let shutdown = Arc::new(AtomicBool::new(false));
620        let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
621        let (raw_tx, raw_rx) = mpsc::channel();
622        let config = WatcherFilterConfig::new(root, None);
623        let mut filter = WatcherFilterThread::new(
624            config,
625            matcher,
626            generation,
627            dispatch_tx,
628            Arc::clone(&shutdown),
629        );
630        let handle = thread::spawn(move || filter.run(raw_rx));
631
632        let mut granular = notify::Event::new(EventKind::Create(CreateKind::File));
633        granular.paths.push(pending);
634        raw_tx.send(Ok(granular)).unwrap();
635        raw_tx
636            .send(Ok(
637                notify::Event::new(EventKind::Other).set_flag(Flag::Rescan)
638            ))
639            .unwrap();
640
641        let event = dispatch_rx
642            .recv_timeout(Duration::from_secs(2))
643            .expect("rescan event");
644        assert_eq!(event, WatcherDispatchEvent::RescanRequired);
645        assert!(
646            dispatch_rx
647                .recv_timeout(WATCHER_FLUSH_WINDOW + Duration::from_millis(100))
648                .is_err(),
649            "pending granular paths should be cleared by a rescan signal"
650        );
651
652        shutdown.store(true, Ordering::SeqCst);
653        drop(raw_tx);
654        handle.join().unwrap();
655    }
656
657    #[test]
658    fn filters_gitignored_paths_with_shared_matcher() {
659        let tmp = TempDir::new().unwrap();
660        let root = std::fs::canonicalize(tmp.path()).unwrap();
661        std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
662        std::fs::create_dir_all(root.join("ignored")).unwrap();
663        std::fs::write(root.join("ignored/file.ts"), "ignored").unwrap();
664        std::fs::write(root.join("kept.ts"), "kept").unwrap();
665        let matcher = shared_matcher(&root);
666        let config = WatcherFilterConfig::new(root.clone(), None);
667
668        let filtered = filter_watcher_raw_paths_for_test(
669            &config,
670            &matcher,
671            [root.join("ignored/file.ts"), root.join("kept.ts")],
672        );
673
674        assert!(!filtered.changed.contains(&root.join("ignored/file.ts")));
675        assert!(filtered.changed.contains(&root.join("kept.ts")));
676    }
677
678    #[test]
679    fn ignore_rule_paths_are_control_only_for_external_excludes() {
680        let tmp = TempDir::new().unwrap();
681        let root = std::fs::canonicalize(tmp.path()).unwrap();
682        let git_info = root.join(".git").join("info");
683        std::fs::create_dir_all(&git_info).unwrap();
684        let exclude = git_info.join("exclude");
685        std::fs::write(&exclude, "ignored/\n").unwrap();
686        let matcher = Arc::new(RwLock::new(None));
687        let config = WatcherFilterConfig::new(root, None);
688
689        let filtered = filter_watcher_raw_paths_for_test(&config, &matcher, [exclude]);
690
691        assert!(filtered.ignore_file_changed);
692        assert!(filtered.changed.is_empty());
693    }
694
695    #[test]
696    fn root_deleted_sends_control_and_exits() {
697        let tmp = TempDir::new().unwrap();
698        let root = std::fs::canonicalize(tmp.path()).unwrap();
699        let matcher = Arc::new(RwLock::new(None));
700        let generation = Arc::new(AtomicU64::new(0));
701        let shutdown = Arc::new(AtomicBool::new(false));
702        let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
703        let (raw_tx, raw_rx) = mpsc::channel();
704        let config = WatcherFilterConfig::new(root.clone(), None);
705        let mut filter = WatcherFilterThread::new(
706            config,
707            matcher,
708            generation,
709            dispatch_tx,
710            Arc::clone(&shutdown),
711        );
712        let handle = thread::spawn(move || filter.run(raw_rx));
713        let _raw_tx = raw_tx;
714        std::fs::remove_dir_all(&root).unwrap();
715
716        let event = dispatch_rx
717            .recv_timeout(Duration::from_secs(2))
718            .expect("root deleted event");
719        assert_eq!(event, WatcherDispatchEvent::RootDeleted);
720        shutdown.store(true, Ordering::SeqCst);
721        handle.join().unwrap();
722    }
723}