Skip to main content

aft/
watcher_filter.rs

1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2#[cfg(any(target_os = "macos", target_os = "linux", test))]
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
6#[cfg(test)]
7use std::sync::OnceLock;
8use std::sync::{mpsc, Arc, Mutex, RwLock};
9use std::thread::{self, JoinHandle};
10use std::time::{Duration, Instant};
11
12use crossbeam_channel::{Receiver, SendTimeoutError, Sender};
13use ignore::gitignore::Gitignore;
14
15pub type SharedGitignore = Arc<RwLock<Option<Arc<Gitignore>>>>;
16
17pub const WATCHER_FLUSH_WINDOW: Duration = Duration::from_millis(250);
18pub const WATCHER_MAX_BATCH_PATHS: usize = 1024;
19pub const WATCHER_DISPATCH_CHANNEL_CAPACITY: usize = 1024;
20#[cfg(any(target_os = "macos", target_os = "linux", test))]
21pub(crate) const WATCHER_EXCLUSION_LIMIT: usize = 8;
22const ROOT_DELETED_CHECK_INTERVAL: Duration = Duration::from_millis(250);
23const GITIGNORE_REBUILD_POLL_INTERVAL: Duration = Duration::from_millis(10);
24const DISPATCH_SEND_POLL_INTERVAL: Duration = Duration::from_millis(50);
25const WATCHER_ATTRIBUTION_RING_CAPACITY: usize = 512;
26const WATCHER_OVERFLOW_PREFIX_LIMIT: usize = 5;
27const WATCHER_OBSERVED_EXCLUSION_LIMIT: usize = 32;
28
29#[derive(Debug, Clone)]
30pub struct WatcherFilterConfig {
31    pub project_root: PathBuf,
32    pub git_common_dir: Option<PathBuf>,
33    counters: Arc<crate::context::WatcherCounters>,
34}
35
36impl WatcherFilterConfig {
37    pub fn new(project_root: PathBuf, git_common_dir: Option<PathBuf>) -> Self {
38        let counters = crate::context::watcher_counters_for_root(&project_root);
39        Self {
40            project_root,
41            git_common_dir,
42            counters,
43        }
44    }
45
46    fn git_info_exclude_path(&self) -> PathBuf {
47        self.git_common_dir
48            .clone()
49            .unwrap_or_else(|| self.project_root.join(".git"))
50            .join("info")
51            .join("exclude")
52    }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum RescanReason {
57    KernelDropped,
58    UserDropped,
59    Unknown,
60}
61
62impl RescanReason {
63    fn from_event_info(info: Option<&str>) -> Self {
64        match info {
65            Some("rescan: kernel dropped") => Self::KernelDropped,
66            Some("rescan: user dropped") => Self::UserDropped,
67            _ => Self::Unknown,
68        }
69    }
70
71    pub(crate) fn as_str(self) -> &'static str {
72        match self {
73            Self::KernelDropped => "kernel_dropped",
74            Self::UserDropped => "user_dropped",
75            Self::Unknown => "unknown",
76        }
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum WatcherDispatchEvent {
82    Paths(Vec<PathBuf>),
83    RescanRequired(RescanReason),
84    IgnoreRulesChanged { path: PathBuf },
85    RootDeleted,
86    Error(String),
87}
88
89pub struct WatcherThreadHandle {
90    shutdown: Arc<AtomicBool>,
91    join: Option<JoinHandle<()>>,
92}
93
94/// Result of a bounded watcher-thread join.
95pub enum WatcherJoinOutcome {
96    Joined,
97    TimedOut(JoinHandle<()>),
98}
99
100impl WatcherThreadHandle {
101    pub fn new(shutdown: Arc<AtomicBool>, join: JoinHandle<()>) -> Self {
102        Self {
103            shutdown,
104            join: Some(join),
105        }
106    }
107
108    pub fn request_shutdown(&self) {
109        self.shutdown.store(true, Ordering::SeqCst);
110    }
111
112    pub fn is_finished(&self) -> bool {
113        self.join.as_ref().is_none_or(|join| join.is_finished())
114    }
115
116    pub fn shutdown_and_join(mut self) {
117        self.request_shutdown();
118        if let Some(join) = self.join.take() {
119            let _ = join.join();
120        }
121    }
122
123    /// Request shutdown and wait only up to `timeout` for the watcher thread.
124    /// The caller owns the still-live join handle on timeout and can monitor it
125    /// without blocking an executor or transport loop.
126    pub fn shutdown_and_join_timeout(mut self, timeout: Duration) -> WatcherJoinOutcome {
127        self.request_shutdown();
128        let Some(join) = self.join.take() else {
129            return WatcherJoinOutcome::Joined;
130        };
131        let deadline = Instant::now() + timeout;
132        while !join.is_finished() && Instant::now() < deadline {
133            thread::sleep(Duration::from_millis(10));
134        }
135        if join.is_finished() {
136            let _ = join.join();
137            WatcherJoinOutcome::Joined
138        } else {
139            WatcherJoinOutcome::TimedOut(join)
140        }
141    }
142}
143
144impl Drop for WatcherThreadHandle {
145    fn drop(&mut self) {
146        self.request_shutdown();
147    }
148}
149
150pub fn watcher_dispatch_channel() -> (Sender<WatcherDispatchEvent>, Receiver<WatcherDispatchEvent>)
151{
152    crossbeam_channel::bounded(WATCHER_DISPATCH_CHANNEL_CAPACITY)
153}
154
155/// Decide whether a `notify::Event` represents a real content change worth
156/// invalidating cached state for.
157pub fn watcher_event_invalidates(kind: &notify::EventKind) -> bool {
158    use notify::event::{MetadataKind, ModifyKind};
159    use notify::EventKind;
160    match kind {
161        EventKind::Create(_) | EventKind::Remove(_) => true,
162        EventKind::Modify(ModifyKind::Metadata(meta)) => !matches!(
163            meta,
164            MetadataKind::AccessTime
165                | MetadataKind::Permissions
166                | MetadataKind::Ownership
167                | MetadataKind::Extended
168        ),
169        EventKind::Modify(_) => true,
170        _ => false,
171    }
172}
173
174pub fn watcher_path_is_infra_skip(path: &Path) -> bool {
175    path.components().any(|c| {
176        matches!(c, Component::Normal(name) if matches!(
177            name.to_str().unwrap_or(""),
178            ".git" | ".opencode" | ".alfonso" | ".gsd" | "node_modules" | "target"
179        ))
180    })
181}
182
183/// High-churn ignored directories that can be dropped from the raw event stream
184/// before paying for a `realpath` canonicalization.
185///
186/// A build writes hundreds of thousands of files under `target/` (or
187/// `node_modules/` for JS installs); FSEvents delivers every one to AFT, and
188/// canonicalizing each just to drop it later in the filter pegs the
189/// single-threaded watcher loop. This is a pure path-component scan (no syscall),
190/// so the flood is rejected almost for free.
191///
192/// This deliberately omits `.git`: `.git/info/exclude` changes the corpus ignore
193/// set, and dropping `.git` here would hide them from the ignore-relevance check
194/// in the full filter. `.git` churn is small next to `target/`, so it stays on
195/// the canonicalizing path.
196fn watcher_path_is_high_churn_infra(path: &Path) -> bool {
197    path.components().any(|c| {
198        matches!(c, Component::Normal(name) if matches!(
199            name.to_str().unwrap_or(""),
200            ".opencode" | ".alfonso" | ".gsd" | "node_modules" | "target"
201        ))
202    })
203}
204
205fn watcher_path_is_ignore_file(path: &Path) -> bool {
206    path.file_name()
207        .map(|n| n == ".gitignore" || n == ".aftignore")
208        .unwrap_or(false)
209}
210
211fn watcher_same_path(path: &Path, target: &Path) -> bool {
212    if path == target {
213        return true;
214    }
215
216    std::fs::canonicalize(target)
217        .map(|target| path == target)
218        .unwrap_or(false)
219}
220
221fn watcher_path_is_git_info_exclude(config: &WatcherFilterConfig, path: &Path) -> bool {
222    watcher_same_path(path, &config.git_info_exclude_path())
223}
224
225fn watcher_path_is_global_gitignore(path: &Path) -> bool {
226    ignore::gitignore::gitconfig_excludes_path()
227        .as_deref()
228        .is_some_and(|global_ignore| watcher_same_path(path, global_ignore))
229}
230
231fn watcher_path_can_change_corpus_ignore(config: &WatcherFilterConfig, path: &Path) -> bool {
232    if watcher_path_is_global_gitignore(path) {
233        return true;
234    }
235    if watcher_path_is_git_info_exclude(config, path) {
236        return true;
237    }
238    if !path.starts_with(&config.project_root) {
239        return false;
240    }
241
242    watcher_path_is_ignore_file(path) && !watcher_path_is_infra_skip(path)
243}
244
245pub fn canonicalize_watcher_path(path: PathBuf) -> PathBuf {
246    if let Ok(canonical) = std::fs::canonicalize(&path) {
247        return canonical;
248    }
249
250    let parent = path.parent().map(Path::to_path_buf);
251    let file_name = path.file_name().map(std::ffi::OsStr::to_os_string);
252    match (parent, file_name) {
253        (Some(parent), Some(file_name)) => std::fs::canonicalize(parent)
254            .map(|canonical_parent| canonical_parent.join(file_name))
255            .unwrap_or(path),
256        _ => path,
257    }
258}
259
260pub(crate) fn watcher_path_is_ignored_by_matcher(matcher: &SharedGitignore, path: &Path) -> bool {
261    if watcher_path_is_infra_skip(path) {
262        return true;
263    }
264
265    let guard = matcher
266        .read()
267        .unwrap_or_else(|poisoned| poisoned.into_inner());
268    watcher_path_is_ignored(guard.as_deref(), path)
269}
270
271fn watcher_path_is_ignored(matcher: Option<&Gitignore>, path: &Path) -> bool {
272    matcher.is_some_and(|matcher| {
273        path.starts_with(matcher.path())
274            && matcher
275                .matched_path_or_any_parents(path, path.is_dir())
276                .is_ignore()
277    })
278}
279
280/// Find ignored directory boundaries that an OS watcher can omit entirely.
281///
282/// `.git` always owns the first slot. A prefix observed in the event ring before
283/// an overflow outranks fixed fallbacks; otherwise common build/install outputs
284/// are preferred in the order documented by `FIXED_EXCLUSION_PRIORITY`.
285#[cfg(any(target_os = "macos", target_os = "linux", test))]
286pub(crate) fn derive_excluded_subtrees(
287    root: &Path,
288    matcher: &SharedGitignore,
289    max_paths: Option<usize>,
290) -> Vec<PathBuf> {
291    const FIXED_EXCLUSION_PRIORITY: [&str; 8] = [
292        "target",
293        "node_modules",
294        "dist",
295        "build",
296        ".next",
297        "tmp",
298        ".bench",
299        "coverage",
300    ];
301
302    #[derive(Debug)]
303    struct Candidate {
304        path: PathBuf,
305        observed_count: Option<u64>,
306        fixed_priority: usize,
307        is_git: bool,
308    }
309
310    let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
311    let matcher = matcher
312        .read()
313        .unwrap_or_else(|poisoned| poisoned.into_inner())
314        .clone();
315    let observed = crate::context::watcher_counters_for_root(&root)
316        .observed_exclusion_prefixes()
317        .into_iter()
318        .map(|prefix| (PathBuf::from(prefix.prefix), prefix.count))
319        .collect::<BTreeMap<_, _>>();
320    let root_git = root.join(".git");
321    let mut candidates = Vec::<Candidate>::new();
322    let mut stack = vec![root.clone()];
323
324    while let Some(directory) = stack.pop() {
325        let Ok(entries) = fs::read_dir(&directory) else {
326            continue;
327        };
328        for entry in entries.flatten() {
329            let path = entry.path();
330            if !entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
331                continue;
332            }
333            let is_git = path == root_git;
334            if is_git || watcher_path_is_ignored(matcher.as_deref(), &path) {
335                let relative = path.strip_prefix(&root).unwrap_or(&path);
336                let fixed_priority = path
337                    .file_name()
338                    .and_then(|name| name.to_str())
339                    .and_then(|name| {
340                        FIXED_EXCLUSION_PRIORITY
341                            .iter()
342                            .position(|priority| name == *priority)
343                    })
344                    .unwrap_or(FIXED_EXCLUSION_PRIORITY.len());
345                let observed_count = observed.get(relative).copied();
346                candidates.push(Candidate {
347                    path,
348                    observed_count,
349                    fixed_priority,
350                    is_git,
351                });
352            } else {
353                stack.push(path);
354            }
355        }
356    }
357
358    candidates.sort_by(|left, right| {
359        right
360            .is_git
361            .cmp(&left.is_git)
362            .then_with(|| {
363                right
364                    .observed_count
365                    .is_some()
366                    .cmp(&left.observed_count.is_some())
367            })
368            .then_with(|| {
369                right
370                    .observed_count
371                    .unwrap_or_default()
372                    .cmp(&left.observed_count.unwrap_or_default())
373            })
374            .then_with(|| left.fixed_priority.cmp(&right.fixed_priority))
375            .then_with(|| left.path.cmp(&right.path))
376    });
377    let mut paths = candidates
378        .into_iter()
379        .map(|candidate| candidate.path)
380        .collect::<Vec<_>>();
381    if let Some(max_paths) = max_paths {
382        paths.truncate(max_paths);
383    }
384    paths
385}
386
387const WATCHER_OBSERVATION_STATE_PREFIX: &str = "watcher.observed_exclusion_prefixes";
388
389fn watcher_observation_state_key(root: &Path) -> String {
390    format!(
391        "{WATCHER_OBSERVATION_STATE_PREFIX}:{}",
392        crate::path_identity::project_scope_key(root)
393    )
394}
395
396fn valid_observed_exclusion_prefix(prefix: &crate::context::WatcherOverflowPrefix) -> bool {
397    prefix.count > 0
398        && !prefix.prefix.is_empty()
399        && Path::new(&prefix.prefix)
400            .components()
401            .all(|component| matches!(component, Component::Normal(_)))
402}
403
404pub(crate) fn load_watcher_observations(
405    root: &Path,
406    counters: &crate::context::WatcherCounters,
407    db: Option<&Arc<Mutex<crate::db::TrackedConnection>>>,
408) {
409    let Some(db) = db else {
410        return;
411    };
412    let conn = db.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
413    let Ok(Some(raw)) =
414        crate::db::state::get_host_state(&conn, &watcher_observation_state_key(root))
415    else {
416        return;
417    };
418    let Ok(mut prefixes) = serde_json::from_str::<Vec<crate::context::WatcherOverflowPrefix>>(&raw)
419    else {
420        return;
421    };
422    prefixes.retain(valid_observed_exclusion_prefix);
423    prefixes.truncate(WATCHER_OBSERVED_EXCLUSION_LIMIT);
424    counters.set_observed_exclusion_prefixes(prefixes);
425}
426
427pub(crate) fn persist_watcher_observations(
428    root: &Path,
429    counters: &crate::context::WatcherCounters,
430    db: Option<&Arc<Mutex<crate::db::TrackedConnection>>>,
431) {
432    let Some(db) = db else {
433        return;
434    };
435    let prefixes = counters.observed_exclusion_prefixes();
436    let Ok(value) = serde_json::to_string(&prefixes) else {
437        return;
438    };
439    let now_ms = std::time::SystemTime::now()
440        .duration_since(std::time::UNIX_EPOCH)
441        .unwrap_or_default()
442        .as_millis()
443        .min(i64::MAX as u128) as i64;
444    let conn = db.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
445    if let Err(error) = crate::db::state::set_host_state(
446        &conn,
447        &watcher_observation_state_key(root),
448        &value,
449        now_ms,
450    ) {
451        crate::slog_warn!(
452            "failed to persist watcher overflow prefixes for {}: {}",
453            root.display(),
454            error
455        );
456    }
457}
458
459#[derive(Debug, Default, Clone, PartialEq, Eq)]
460pub struct FilteredWatcherPaths {
461    pub changed: BTreeSet<PathBuf>,
462    pub ignore_file_changed: bool,
463}
464
465fn filter_canonical_paths(
466    config: &WatcherFilterConfig,
467    matcher: &SharedGitignore,
468    raw_paths: BTreeSet<PathBuf>,
469) -> FilteredWatcherPaths {
470    let ignore_file_changed = raw_paths
471        .iter()
472        .any(|path| watcher_path_can_change_corpus_ignore(config, path));
473
474    let changed = raw_paths
475        .into_iter()
476        .filter(|path| {
477            if watcher_path_is_infra_skip(path) {
478                return false;
479            }
480
481            if watcher_path_is_global_gitignore(path)
482                || watcher_path_is_git_info_exclude(config, path)
483            {
484                return false;
485            }
486
487            if watcher_path_is_ignored_by_matcher(matcher, path) {
488                return false;
489            }
490            true
491        })
492        .collect();
493
494    FilteredWatcherPaths {
495        changed,
496        ignore_file_changed,
497    }
498}
499
500pub fn filter_watcher_raw_paths_for_test<I>(
501    config: &WatcherFilterConfig,
502    matcher: &SharedGitignore,
503    raw_paths: I,
504) -> FilteredWatcherPaths
505where
506    I: IntoIterator<Item = PathBuf>,
507{
508    let raw_paths = raw_paths
509        .into_iter()
510        .map(canonicalize_watcher_path)
511        .collect::<BTreeSet<_>>();
512    filter_canonical_paths(config, matcher, raw_paths)
513}
514
515pub fn run_watcher_thread<W, E, F>(
516    config: WatcherFilterConfig,
517    extra_watch_paths: Vec<PathBuf>,
518    matcher: SharedGitignore,
519    matcher_generation: Arc<AtomicU64>,
520    dispatch_tx: Sender<WatcherDispatchEvent>,
521    shutdown: Arc<AtomicBool>,
522    attach: F,
523) where
524    W: Send + 'static,
525    E: std::fmt::Display,
526    F: FnOnce(PathBuf, Vec<PathBuf>, mpsc::Sender<notify::Result<notify::Event>>) -> Result<W, E>,
527{
528    let (raw_tx, raw_rx) = mpsc::channel();
529    let root_path = config.project_root.clone();
530    match attach(root_path.clone(), extra_watch_paths, raw_tx) {
531        Ok(_watcher) => {
532            if shutdown.load(Ordering::SeqCst) {
533                return;
534            }
535            crate::slog_info!("watcher started: {}", root_path.display());
536            let mut filter = WatcherFilterThread::new(
537                config,
538                matcher,
539                matcher_generation,
540                dispatch_tx,
541                shutdown,
542            );
543            filter.run(raw_rx);
544        }
545        Err(error) => {
546            if !shutdown.load(Ordering::SeqCst) {
547                log::debug!(
548                    "watcher init failed: {} — callers will work with stale data",
549                    error
550                );
551                let _ = dispatch_tx.send(WatcherDispatchEvent::Error(format!(
552                    "watcher init failed: {error}"
553                )));
554            }
555        }
556    }
557}
558
559struct WatcherFilterThread {
560    config: WatcherFilterConfig,
561    matcher: SharedGitignore,
562    matcher_generation: Arc<AtomicU64>,
563    dispatch_tx: Sender<WatcherDispatchEvent>,
564    shutdown: Arc<AtomicBool>,
565    raw_paths: BTreeSet<PathBuf>,
566    recent_paths: VecDeque<(PathBuf, Instant)>,
567    flush_deadline: Option<Instant>,
568}
569
570impl WatcherFilterThread {
571    fn new(
572        config: WatcherFilterConfig,
573        matcher: SharedGitignore,
574        matcher_generation: Arc<AtomicU64>,
575        dispatch_tx: Sender<WatcherDispatchEvent>,
576        shutdown: Arc<AtomicBool>,
577    ) -> Self {
578        Self {
579            config,
580            matcher,
581            matcher_generation,
582            dispatch_tx,
583            shutdown,
584            raw_paths: BTreeSet::new(),
585            recent_paths: VecDeque::with_capacity(WATCHER_ATTRIBUTION_RING_CAPACITY),
586            flush_deadline: None,
587        }
588    }
589
590    fn run(&mut self, raw_rx: mpsc::Receiver<notify::Result<notify::Event>>) {
591        loop {
592            if self.shutdown.load(Ordering::SeqCst) {
593                self.flush_pending();
594                return;
595            }
596            if self.project_root_was_deleted() {
597                self.raw_paths.clear();
598                let _ = self.send_dispatch(WatcherDispatchEvent::RootDeleted);
599                return;
600            }
601            if self.flush_deadline_reached() {
602                if !self.flush_pending() {
603                    return;
604                }
605                continue;
606            }
607
608            match raw_rx.recv_timeout(self.next_recv_timeout()) {
609                Ok(Ok(event)) => {
610                    self.config.counters.note_raw_event();
611                    if event.need_rescan() {
612                        let reason = RescanReason::from_event_info(event.info());
613                        let during_rescan = self.log_overflow(reason);
614                        self.raw_paths.clear();
615                        self.flush_deadline = None;
616                        if !during_rescan
617                            && !self.send_dispatch(WatcherDispatchEvent::RescanRequired(reason))
618                        {
619                            return;
620                        }
621                        continue;
622                    }
623                    self.record_recent_paths(&event.paths);
624                    if watcher_event_invalidates(&event.kind) {
625                        self.config.counters.note_invalidating_event();
626                        if !self.push_raw_paths(event.paths) {
627                            return;
628                        }
629                    }
630                }
631                Ok(Err(error)) => {
632                    let _ = self.send_dispatch(WatcherDispatchEvent::Error(error.to_string()));
633                    return;
634                }
635                Err(mpsc::RecvTimeoutError::Timeout) => {
636                    if !self.flush_pending() {
637                        return;
638                    }
639                }
640                Err(mpsc::RecvTimeoutError::Disconnected) => {
641                    if !self.shutdown.load(Ordering::SeqCst) {
642                        let _ = self.send_dispatch(WatcherDispatchEvent::Error(
643                            "watcher channel disconnected".to_string(),
644                        ));
645                    }
646                    return;
647                }
648            }
649        }
650    }
651
652    fn project_root_was_deleted(&self) -> bool {
653        !self.config.project_root.exists()
654    }
655
656    fn record_recent_paths(&mut self, paths: &[PathBuf]) {
657        let arrived_at = Instant::now();
658        for path in paths {
659            let relative = path
660                .strip_prefix(&self.config.project_root)
661                .map(Path::to_path_buf)
662                .unwrap_or_else(|_| {
663                    PathBuf::from("<external>")
664                        .join(path.file_name().unwrap_or_else(|| path.as_os_str()))
665                });
666            if self.recent_paths.len() == WATCHER_ATTRIBUTION_RING_CAPACITY {
667                self.recent_paths.pop_front();
668            }
669            self.recent_paths.push_back((relative, arrived_at));
670        }
671    }
672
673    fn overflow_prefixes(&self) -> Vec<crate::context::WatcherOverflowPrefix> {
674        let mut counts = BTreeMap::<String, u64>::new();
675        for (path, _) in &self.recent_paths {
676            // Root-relative, first two components, joined with `/` on every
677            // platform: the prefix is rendered in the overflow log line (one
678            // grammar for the fleet's log readers) and persisted for slot
679            // ranking, and `Path::new` on Windows reads `/` back as a
680            // separator when the prefix is turned into an exclusion path.
681            let prefix = path
682                .components()
683                .filter_map(|component| match component {
684                    Component::Normal(name) => Some(name.to_string_lossy()),
685                    _ => None,
686                })
687                .take(2)
688                .collect::<Vec<_>>()
689                .join("/");
690            if prefix.is_empty() {
691                continue;
692            }
693            *counts.entry(prefix).or_default() += 1;
694        }
695        let mut prefixes = counts
696            .into_iter()
697            .map(|(prefix, count)| crate::context::WatcherOverflowPrefix { prefix, count })
698            .collect::<Vec<_>>();
699        prefixes.sort_by(|left, right| {
700            right
701                .count
702                .cmp(&left.count)
703                .then_with(|| left.prefix.cmp(&right.prefix))
704        });
705        prefixes.truncate(WATCHER_OVERFLOW_PREFIX_LIMIT);
706        prefixes
707    }
708
709    fn observed_exclusion_prefixes(&self) -> Vec<crate::context::WatcherOverflowPrefix> {
710        let matcher = self
711            .matcher
712            .read()
713            .unwrap_or_else(std::sync::PoisonError::into_inner)
714            .clone();
715        let root_git = self.config.project_root.join(".git");
716        let mut counts = BTreeMap::<String, u64>::new();
717        for (path, _) in &self.recent_paths {
718            let mut relative = PathBuf::new();
719            let mut absolute = self.config.project_root.clone();
720            for component in path.components() {
721                let Component::Normal(name) = component else {
722                    continue;
723                };
724                relative.push(name);
725                absolute.push(name);
726                if absolute == root_git || watcher_path_is_ignored(matcher.as_deref(), &absolute) {
727                    *counts
728                        .entry(relative.to_string_lossy().into_owned())
729                        .or_default() += 1;
730                    break;
731                }
732            }
733        }
734        let mut prefixes = counts
735            .into_iter()
736            .map(|(prefix, count)| crate::context::WatcherOverflowPrefix { prefix, count })
737            .collect::<Vec<_>>();
738        prefixes.sort_by(|left, right| {
739            right
740                .count
741                .cmp(&left.count)
742                .then_with(|| left.prefix.cmp(&right.prefix))
743        });
744        prefixes.truncate(WATCHER_OBSERVED_EXCLUSION_LIMIT);
745        prefixes
746    }
747
748    fn log_overflow(&self, reason: RescanReason) -> bool {
749        let prefixes = self.overflow_prefixes();
750        self.config
751            .counters
752            .set_observed_exclusion_prefixes(self.observed_exclusion_prefixes());
753        let during_rescan = self.config.counters.note_overflow(reason, prefixes.clone());
754        let backend = self.config.counters.backend_exclusions();
755        let exclusions = backend
756            .paths
757            .iter()
758            .map(|path| {
759                path.strip_prefix(&self.config.project_root)
760                    .unwrap_or(path)
761                    .display()
762                    .to_string()
763            })
764            .collect::<Vec<_>>()
765            .join(",");
766        let prefixes = prefixes
767            .iter()
768            .map(|prefix| format!("{}:{}", prefix.prefix, prefix.count))
769            .collect::<Vec<_>>()
770            .join(",");
771        let span_ms = self
772            .recent_paths
773            .front()
774            .zip(self.recent_paths.back())
775            .map(|((_, first), (_, last))| {
776                last.saturating_duration_since(*first)
777                    .as_millis()
778                    .min(u64::MAX as u128) as u64
779            })
780            .unwrap_or(0);
781        let queue_depth = backend
782            .queue_depth
783            .map(|depth| depth.to_string())
784            .unwrap_or_else(|| "unavailable".to_string());
785        let line = format!(
786            "watcher overflow: reason={} root={} exclusions=[{}] matcher_generation={} top_prefixes=[{}] ring_span_ms={} queue_depth={} rescan_in_progress={}",
787            reason.as_str(),
788            self.config.project_root.display(),
789            exclusions,
790            backend.matcher_generation,
791            prefixes,
792            span_ms,
793            queue_depth,
794            during_rescan
795        );
796        emit_watcher_overflow_log(line);
797        during_rescan
798    }
799
800    fn push_raw_paths(&mut self, paths: Vec<PathBuf>) -> bool {
801        for path in paths {
802            // Drop high-churn ignored dirs (target/, node_modules/, agent infra)
803            // on the RAW path before canonicalizing. `canonicalize_watcher_path`
804            // is a realpath syscall; a build floods FSEvents with hundreds of
805            // thousands of target/ paths, and paying a syscall per path only to
806            // discard them later pegged this single watcher thread. The full
807            // filter still drops these (watcher_path_is_infra_skip), so this is a
808            // pure perf short-circuit with no behavior change.
809            if watcher_path_is_high_churn_infra(&path) {
810                continue;
811            }
812            // Canonicalize at intake so the set keys (and downstream consumers)
813            // see normalized paths — this is what collapses macOS /var ->
814            // /private/var aliasing and matches the callgraph/semantic/search
815            // cache keys. Same-file repeats within the window still dedup here;
816            // the high-churn flood is already dropped above, before this syscall.
817            self.raw_paths.insert(canonicalize_watcher_path(path));
818        }
819        if !self.raw_paths.is_empty() && self.flush_deadline.is_none() {
820            self.flush_deadline = Some(Instant::now() + WATCHER_FLUSH_WINDOW);
821        }
822        if self.raw_paths.len() >= WATCHER_MAX_BATCH_PATHS {
823            return self.flush_pending();
824        }
825        true
826    }
827
828    fn next_recv_timeout(&self) -> Duration {
829        let root_check = ROOT_DELETED_CHECK_INTERVAL;
830        match self.flush_deadline {
831            Some(deadline) => deadline
832                .saturating_duration_since(Instant::now())
833                .min(root_check),
834            None => root_check,
835        }
836    }
837
838    fn flush_deadline_reached(&self) -> bool {
839        self.flush_deadline
840            .is_some_and(|deadline| Instant::now() >= deadline)
841    }
842
843    fn flush_pending(&mut self) -> bool {
844        if self.raw_paths.is_empty() {
845            self.flush_deadline = None;
846            return true;
847        }
848
849        let raw_paths = std::mem::take(&mut self.raw_paths);
850        self.flush_deadline = None;
851        let ignore_path = raw_paths
852            .iter()
853            .find(|path| watcher_path_can_change_corpus_ignore(&self.config, path))
854            .cloned();
855        let ignore_file_changed = ignore_path.is_some();
856        if let Some(path) = ignore_path {
857            let observed_generation = self.matcher_generation.load(Ordering::SeqCst);
858            if !self.send_dispatch(WatcherDispatchEvent::IgnoreRulesChanged { path }) {
859                return false;
860            }
861            if !self.wait_for_gitignore_rebuild(observed_generation) {
862                return false;
863            }
864        }
865
866        let filtered = filter_canonical_paths(&self.config, &self.matcher, raw_paths);
867        debug_assert_eq!(filtered.ignore_file_changed, ignore_file_changed);
868        self.config
869            .counters
870            .note_paths_after_gitignore(filtered.changed.len());
871        if filtered.changed.is_empty() {
872            return true;
873        }
874        let paths = filtered.changed.into_iter().collect::<Vec<_>>();
875        let path_count = paths.len();
876        if !self.send_dispatch(WatcherDispatchEvent::Paths(paths)) {
877            return false;
878        }
879        self.config.counters.note_paths_dispatched(path_count);
880        true
881    }
882
883    fn wait_for_gitignore_rebuild(&self, observed_generation: u64) -> bool {
884        while !self.shutdown.load(Ordering::SeqCst)
885            && self.matcher_generation.load(Ordering::SeqCst) == observed_generation
886        {
887            if self.project_root_was_deleted() {
888                let _ = self.send_dispatch(WatcherDispatchEvent::RootDeleted);
889                return false;
890            }
891            thread::sleep(GITIGNORE_REBUILD_POLL_INTERVAL);
892        }
893        !self.shutdown.load(Ordering::SeqCst)
894    }
895
896    fn send_dispatch(&self, event: WatcherDispatchEvent) -> bool {
897        let mut event = event;
898        loop {
899            match self
900                .dispatch_tx
901                .send_timeout(event, DISPATCH_SEND_POLL_INTERVAL)
902            {
903                Ok(()) => return true,
904                Err(SendTimeoutError::Timeout(returned)) => {
905                    if self.shutdown.load(Ordering::SeqCst) {
906                        return false;
907                    }
908                    event = returned;
909                }
910                Err(SendTimeoutError::Disconnected(_)) => return false,
911            }
912        }
913    }
914}
915
916fn emit_watcher_overflow_log(line: String) {
917    crate::slog_warn!("{line}");
918    #[cfg(test)]
919    WATCHER_OVERFLOW_LOGS_FOR_TEST
920        .get_or_init(|| Mutex::new(Vec::new()))
921        .lock()
922        .unwrap_or_else(std::sync::PoisonError::into_inner)
923        .push(line);
924}
925
926#[cfg(test)]
927static WATCHER_OVERFLOW_LOGS_FOR_TEST: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
928
929#[cfg(test)]
930pub(crate) fn take_watcher_overflow_logs_for_test() -> Vec<String> {
931    std::mem::take(
932        &mut *WATCHER_OVERFLOW_LOGS_FOR_TEST
933            .get_or_init(|| Mutex::new(Vec::new()))
934            .lock()
935            .unwrap_or_else(std::sync::PoisonError::into_inner),
936    )
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use ignore::gitignore::GitignoreBuilder;
943    use notify::event::{
944        AccessKind, AccessMode, CreateKind, DataChange, Flag, MetadataKind, ModifyKind,
945    };
946    use notify::EventKind;
947    use tempfile::TempDir;
948
949    fn shared_matcher(root: &Path) -> SharedGitignore {
950        let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
951        let mut builder = GitignoreBuilder::new(&root);
952        let ignore = root.join(".gitignore");
953        if ignore.exists() {
954            if let Some(error) = builder.add(&ignore) {
955                panic!("gitignore parse error: {error}");
956            }
957        }
958        let matcher = builder.build().unwrap();
959        let matcher = (matcher.num_ignores() > 0).then(|| Arc::new(matcher));
960        Arc::new(RwLock::new(matcher))
961    }
962
963    #[test]
964    fn overflow_volume_promotes_deep_ignored_prefix_into_next_exclusion_set() {
965        let root = TempDir::new().unwrap();
966        std::fs::create_dir(root.path().join(".git")).unwrap();
967        let fallback = [
968            "target",
969            "node_modules",
970            "dist",
971            "build",
972            ".next",
973            "tmp",
974            ".bench",
975            "coverage",
976            "aaa",
977            "bbb",
978        ];
979        for directory in fallback {
980            std::fs::create_dir_all(root.path().join(directory)).unwrap();
981        }
982        let hot = root.path().join("packages/opencode-plugin/tmp");
983        std::fs::create_dir_all(&hot).unwrap();
984        std::fs::write(
985            root.path().join(".gitignore"),
986            format!(
987                "{}packages/*/tmp/\n",
988                fallback
989                    .iter()
990                    .map(|directory| format!("{directory}/\n"))
991                    .collect::<String>()
992            ),
993        )
994        .unwrap();
995        let canonical_root = std::fs::canonicalize(root.path()).unwrap();
996        let hot = std::fs::canonicalize(hot).unwrap();
997        let matcher = shared_matcher(&canonical_root);
998        let generation = Arc::new(AtomicU64::new(4));
999        let shutdown = Arc::new(AtomicBool::new(false));
1000        let (dispatch_tx, dispatch_rx) = crossbeam_channel::bounded(1);
1001        let (raw_tx, raw_rx) = mpsc::channel();
1002        let config = WatcherFilterConfig::new(canonical_root.clone(), None);
1003        let mut filter = WatcherFilterThread::new(
1004            config,
1005            Arc::clone(&matcher),
1006            generation,
1007            dispatch_tx,
1008            Arc::clone(&shutdown),
1009        );
1010        let handle = thread::spawn(move || filter.run(raw_rx));
1011
1012        for index in 0..64 {
1013            raw_tx
1014                .send(Ok(notify::Event::new(EventKind::Create(CreateKind::File))
1015                    .add_path(hot.join(format!("host-install-{index}")))))
1016                .unwrap();
1017        }
1018        raw_tx
1019            .send(Ok(
1020                notify::Event::new(EventKind::Other).set_flag(Flag::Rescan)
1021            ))
1022            .unwrap();
1023        assert_eq!(
1024            dispatch_rx.recv_timeout(Duration::from_secs(2)).unwrap(),
1025            WatcherDispatchEvent::RescanRequired(RescanReason::Unknown)
1026        );
1027        shutdown.store(true, Ordering::SeqCst);
1028        drop(raw_tx);
1029        handle.join().unwrap();
1030
1031        let exclusions =
1032            derive_excluded_subtrees(&canonical_root, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1033        assert_eq!(exclusions[0], canonical_root.join(".git"));
1034        assert_eq!(exclusions[1], hot);
1035    }
1036
1037    #[test]
1038    fn observed_exclusion_ranking_survives_state_database_reload() {
1039        let root = TempDir::new().unwrap();
1040        let storage = TempDir::new().unwrap();
1041        std::fs::create_dir(root.path().join(".git")).unwrap();
1042        let hot = root.path().join("packages/opencode-plugin/tmp");
1043        std::fs::create_dir_all(&hot).unwrap();
1044        let fallback = [
1045            "target",
1046            "node_modules",
1047            "dist",
1048            "build",
1049            ".next",
1050            "tmp",
1051            ".bench",
1052            "coverage",
1053        ];
1054        for directory in fallback {
1055            std::fs::create_dir(root.path().join(directory)).unwrap();
1056        }
1057        std::fs::write(
1058            root.path().join(".gitignore"),
1059            format!(
1060                "{}packages/*/tmp/\n",
1061                fallback
1062                    .iter()
1063                    .map(|directory| format!("{directory}/\n"))
1064                    .collect::<String>()
1065            ),
1066        )
1067        .unwrap();
1068        let canonical_root = std::fs::canonicalize(root.path()).unwrap();
1069        let hot = std::fs::canonicalize(hot).unwrap();
1070        let matcher = shared_matcher(&canonical_root);
1071        let counters = crate::context::watcher_counters_for_root(&canonical_root);
1072        let db = Arc::new(Mutex::new(
1073            crate::db::open(&storage.path().join("aft.db")).unwrap(),
1074        ));
1075        counters.set_observed_exclusion_prefixes(vec![crate::context::WatcherOverflowPrefix {
1076            prefix: "packages/opencode-plugin/tmp".to_string(),
1077            count: 37,
1078        }]);
1079        persist_watcher_observations(&canonical_root, &counters, Some(&db));
1080        counters.set_observed_exclusion_prefixes(Vec::new());
1081
1082        load_watcher_observations(&canonical_root, &counters, Some(&db));
1083
1084        assert_eq!(
1085            counters.observed_exclusion_prefixes(),
1086            vec![crate::context::WatcherOverflowPrefix {
1087                prefix: "packages/opencode-plugin/tmp".to_string(),
1088                count: 37,
1089            }]
1090        );
1091        let exclusions =
1092            derive_excluded_subtrees(&canonical_root, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1093        assert_eq!(exclusions[0], canonical_root.join(".git"));
1094        assert_eq!(exclusions[1], hot);
1095    }
1096
1097    #[test]
1098    fn exclusion_derivation_uses_fixed_priority_caps_and_skips_missing_directories() {
1099        let root = TempDir::new().unwrap();
1100        std::fs::create_dir(root.path().join(".git")).unwrap();
1101        let priorities = [
1102            "target",
1103            "node_modules",
1104            "dist",
1105            "build",
1106            ".next",
1107            "tmp",
1108            ".bench",
1109            "coverage",
1110        ];
1111        for name in priorities {
1112            std::fs::create_dir(root.path().join(name)).unwrap();
1113        }
1114        std::fs::create_dir(root.path().join("other-generated")).unwrap();
1115        std::fs::write(
1116            root.path().join(".gitignore"),
1117            format!(
1118                "{}other-generated/\nmissing/\n",
1119                priorities
1120                    .iter()
1121                    .rev()
1122                    .map(|name| format!("{name}/\n"))
1123                    .collect::<String>()
1124            ),
1125        )
1126        .unwrap();
1127        let matcher = shared_matcher(root.path());
1128
1129        let exclusions =
1130            derive_excluded_subtrees(root.path(), &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1131
1132        assert_eq!(exclusions.len(), WATCHER_EXCLUSION_LIMIT);
1133        assert_eq!(
1134            exclusions[0],
1135            std::fs::canonicalize(root.path().join(".git")).unwrap()
1136        );
1137        assert_eq!(
1138            exclusions[1..],
1139            priorities[..WATCHER_EXCLUSION_LIMIT - 1]
1140                .iter()
1141                .map(|name| std::fs::canonicalize(root.path().join(name)).unwrap())
1142                .collect::<Vec<_>>()
1143        );
1144        assert!(!exclusions.iter().any(|path| path.ends_with("missing")));
1145        assert!(!exclusions
1146            .iter()
1147            .any(|path| path.ends_with("other-generated")));
1148    }
1149
1150    #[test]
1151    fn event_kind_filter_accepts_content_changes_only() {
1152        assert!(watcher_event_invalidates(&EventKind::Create(
1153            CreateKind::File
1154        )));
1155        assert!(watcher_event_invalidates(&EventKind::Modify(
1156            ModifyKind::Data(DataChange::Content)
1157        )));
1158        assert!(watcher_event_invalidates(&EventKind::Modify(
1159            ModifyKind::Metadata(MetadataKind::WriteTime)
1160        )));
1161        assert!(!watcher_event_invalidates(&EventKind::Modify(
1162            ModifyKind::Metadata(MetadataKind::AccessTime)
1163        )));
1164        assert!(!watcher_event_invalidates(&EventKind::Modify(
1165            ModifyKind::Metadata(MetadataKind::Permissions)
1166        )));
1167        assert!(!watcher_event_invalidates(&EventKind::Access(
1168            AccessKind::Open(AccessMode::Read)
1169        )));
1170        assert!(!watcher_event_invalidates(&EventKind::Other));
1171    }
1172
1173    #[test]
1174    fn high_churn_infra_skip_drops_build_dirs_but_keeps_git_and_source() {
1175        // target/ and node_modules/ are dropped on the raw path before the
1176        // realpath syscall — this is the build-flood short-circuit.
1177        assert!(watcher_path_is_high_churn_infra(Path::new(
1178            "/proj/target/debug/deps/foo.o"
1179        )));
1180        assert!(watcher_path_is_high_churn_infra(Path::new(
1181            "/proj/node_modules/.bin/x"
1182        )));
1183        assert!(watcher_path_is_high_churn_infra(Path::new(
1184            "/proj/.alfonso/notes/x"
1185        )));
1186        // .git is deliberately NOT high-churn-skipped: .git/info/exclude must
1187        // still reach the ignore-relevance check.
1188        assert!(!watcher_path_is_high_churn_infra(Path::new(
1189            "/proj/.git/info/exclude"
1190        )));
1191        // Source files always pass through to canonicalization + filtering.
1192        assert!(!watcher_path_is_high_churn_infra(Path::new(
1193            "/proj/src/main.rs"
1194        )));
1195        // The full filter still drops .git (and everything high-churn does).
1196        assert!(watcher_path_is_infra_skip(Path::new("/proj/.git/index")));
1197    }
1198
1199    #[test]
1200    fn rescan_event_dispatches_control_and_supersedes_pending_paths() {
1201        let tmp = TempDir::new().unwrap();
1202        let root = std::fs::canonicalize(tmp.path()).unwrap();
1203        let pending = root.join("pending.rs");
1204        std::fs::write(&pending, "fn main() {}\n").unwrap();
1205        let matcher = Arc::new(RwLock::new(None));
1206        let generation = Arc::new(AtomicU64::new(0));
1207        let shutdown = Arc::new(AtomicBool::new(false));
1208        let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
1209        let (raw_tx, raw_rx) = mpsc::channel();
1210        let config = WatcherFilterConfig::new(root, None);
1211        let counters = Arc::clone(&config.counters);
1212        let mut filter = WatcherFilterThread::new(
1213            config,
1214            matcher,
1215            generation,
1216            dispatch_tx,
1217            Arc::clone(&shutdown),
1218        );
1219        let handle = thread::spawn(move || filter.run(raw_rx));
1220
1221        let mut granular = notify::Event::new(EventKind::Create(CreateKind::File));
1222        granular.paths.push(pending);
1223        raw_tx.send(Ok(granular)).unwrap();
1224        for (info, expected) in [
1225            (Some("rescan: kernel dropped"), RescanReason::KernelDropped),
1226            (Some("rescan: user dropped"), RescanReason::UserDropped),
1227            (None, RescanReason::Unknown),
1228        ] {
1229            let mut event = notify::Event::new(EventKind::Other).set_flag(Flag::Rescan);
1230            if let Some(info) = info {
1231                event = event.set_info(info);
1232            }
1233            raw_tx.send(Ok(event)).unwrap();
1234            assert_eq!(
1235                dispatch_rx
1236                    .recv_timeout(Duration::from_secs(2))
1237                    .expect("rescan event"),
1238                WatcherDispatchEvent::RescanRequired(expected)
1239            );
1240        }
1241        assert!(
1242            dispatch_rx
1243                .recv_timeout(WATCHER_FLUSH_WINDOW + Duration::from_millis(100))
1244                .is_err(),
1245            "pending granular paths should be cleared by a rescan signal"
1246        );
1247        let snapshot = counters.snapshot();
1248        assert_eq!(snapshot.raw_events_total, 4);
1249        assert_eq!(snapshot.invalidating_events_total, 1);
1250        assert_eq!(snapshot.paths_after_gitignore_total, 0);
1251        assert_eq!(snapshot.paths_dispatched_total, 0);
1252
1253        shutdown.store(true, Ordering::SeqCst);
1254        drop(raw_tx);
1255        handle.join().unwrap();
1256    }
1257
1258    #[test]
1259    fn overflow_log_attributes_excluded_and_nonexcluded_burst_prefixes() {
1260        let tmp = TempDir::new().unwrap();
1261        let root = std::fs::canonicalize(tmp.path()).unwrap();
1262        let target = root.join("target/cache");
1263        let source = root.join("src/generated");
1264        std::fs::create_dir_all(&target).unwrap();
1265        std::fs::create_dir_all(&source).unwrap();
1266        std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
1267        let matcher = shared_matcher(&root);
1268        let generation = Arc::new(AtomicU64::new(7));
1269        let shutdown = Arc::new(AtomicBool::new(false));
1270        let (dispatch_tx, dispatch_rx) = crossbeam_channel::bounded(1);
1271        let (raw_tx, raw_rx) = mpsc::channel();
1272        let config = WatcherFilterConfig::new(root.clone(), None);
1273        config.counters.set_backend_exclusions(
1274            7,
1275            (0..WATCHER_EXCLUSION_LIMIT)
1276                .map(|index| root.join(format!("excluded-{index}")))
1277                .collect(),
1278        );
1279        let counters = Arc::clone(&config.counters);
1280        let mut filter = WatcherFilterThread::new(
1281            config,
1282            matcher,
1283            generation,
1284            dispatch_tx,
1285            Arc::clone(&shutdown),
1286        );
1287        let handle = thread::spawn(move || filter.run(raw_rx));
1288
1289        for index in 0..20 {
1290            raw_tx
1291                .send(Ok(notify::Event::new(EventKind::Create(CreateKind::File))
1292                    .add_path(target.join(format!("artifact-{index}")))))
1293                .unwrap();
1294        }
1295        for index in 0..7 {
1296            raw_tx
1297                .send(Ok(notify::Event::new(EventKind::Create(CreateKind::File))
1298                    .add_path(source.join(format!("source-{index}.rs")))))
1299                .unwrap();
1300        }
1301        raw_tx
1302            .send(Ok(
1303                notify::Event::new(EventKind::Other).set_flag(Flag::Rescan)
1304            ))
1305            .unwrap();
1306        assert_eq!(
1307            dispatch_rx.recv_timeout(Duration::from_secs(2)).unwrap(),
1308            WatcherDispatchEvent::RescanRequired(RescanReason::Unknown)
1309        );
1310
1311        shutdown.store(true, Ordering::SeqCst);
1312        drop(raw_tx);
1313        handle.join().unwrap();
1314
1315        let lines = take_watcher_overflow_logs_for_test();
1316        let line = lines
1317            .iter()
1318            .find(|line| line.contains(&format!("root={}", root.display())))
1319            .unwrap_or_else(|| panic!("missing overflow line for {}: {lines:?}", root.display()));
1320        assert!(line.contains("matcher_generation=7"), "line: {line}");
1321        assert!(line.contains("target/cache:20"), "line: {line}");
1322        assert!(line.contains("src/generated:7"), "line: {line}");
1323        assert!(line.contains("queue_depth=unavailable"), "line: {line}");
1324        assert!(line.contains("rescan_in_progress=false"), "line: {line}");
1325        for index in 0..WATCHER_EXCLUSION_LIMIT {
1326            assert!(line.contains(&format!("excluded-{index}")), "line: {line}");
1327        }
1328        let snapshot = counters.snapshot();
1329        assert_eq!(snapshot.overflows_total, 1);
1330        assert_eq!(snapshot.overflows_during_rescan, 0);
1331        assert_eq!(snapshot.last_overflow_prefixes[0].prefix, "target/cache");
1332        assert_eq!(snapshot.last_overflow_prefixes[0].count, 20);
1333    }
1334
1335    #[test]
1336    fn watcher_thread_records_filter_pipeline_counters() {
1337        let tmp = TempDir::new().unwrap();
1338        let root = std::fs::canonicalize(tmp.path()).unwrap();
1339        let changed = root.join("changed.rs");
1340        std::fs::write(&changed, "fn changed() {}\n").unwrap();
1341        let matcher = Arc::new(RwLock::new(None));
1342        let generation = Arc::new(AtomicU64::new(0));
1343        let shutdown = Arc::new(AtomicBool::new(false));
1344        let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
1345        let (raw_tx, raw_rx) = mpsc::channel();
1346        let config = WatcherFilterConfig::new(root, None);
1347        let counters = Arc::clone(&config.counters);
1348        let mut filter = WatcherFilterThread::new(
1349            config,
1350            matcher,
1351            generation,
1352            dispatch_tx,
1353            Arc::clone(&shutdown),
1354        );
1355        let handle = thread::spawn(move || filter.run(raw_rx));
1356
1357        let mut event = notify::Event::new(EventKind::Create(CreateKind::File));
1358        event.paths.push(changed.clone());
1359        raw_tx.send(Ok(event)).unwrap();
1360        assert_eq!(
1361            dispatch_rx
1362                .recv_timeout(Duration::from_secs(2))
1363                .expect("filtered paths"),
1364            WatcherDispatchEvent::Paths(vec![changed])
1365        );
1366
1367        shutdown.store(true, Ordering::SeqCst);
1368        drop(raw_tx);
1369        handle.join().unwrap();
1370
1371        let snapshot = counters.snapshot();
1372        assert_eq!(snapshot.raw_events_total, 1);
1373        assert_eq!(snapshot.raw_events_since_last_rescan, 1);
1374        assert_eq!(snapshot.invalidating_events_total, 1);
1375        assert_eq!(snapshot.invalidating_events_since_last_rescan, 1);
1376        assert_eq!(snapshot.paths_after_gitignore_total, 1);
1377        assert_eq!(snapshot.paths_after_gitignore_since_last_rescan, 1);
1378        assert_eq!(snapshot.paths_dispatched_total, 1);
1379        assert_eq!(snapshot.paths_dispatched_since_last_rescan, 1);
1380    }
1381
1382    #[test]
1383    fn configured_context_and_filter_thread_share_root_counters() {
1384        let tmp = TempDir::new().unwrap();
1385        let root = std::fs::canonicalize(tmp.path()).unwrap();
1386        let ctx = crate::context::AppContext::new(
1387            crate::context::default_language_provider_factory(),
1388            crate::config::Config::default(),
1389        );
1390        ctx.update_config(|config| config.project_root = Some(root.clone()));
1391        let config = WatcherFilterConfig::new(root, None);
1392
1393        config.counters.note_raw_event();
1394
1395        assert_eq!(ctx.watcher_counters().snapshot().raw_events_total, 1);
1396    }
1397
1398    #[test]
1399    fn filters_gitignored_paths_with_shared_matcher() {
1400        let tmp = TempDir::new().unwrap();
1401        let root = std::fs::canonicalize(tmp.path()).unwrap();
1402        std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
1403        std::fs::create_dir_all(root.join("ignored")).unwrap();
1404        std::fs::write(root.join("ignored/file.ts"), "ignored").unwrap();
1405        std::fs::write(root.join("kept.ts"), "kept").unwrap();
1406        let matcher = shared_matcher(&root);
1407        let config = WatcherFilterConfig::new(root.clone(), None);
1408
1409        let filtered = filter_watcher_raw_paths_for_test(
1410            &config,
1411            &matcher,
1412            [root.join("ignored/file.ts"), root.join("kept.ts")],
1413        );
1414
1415        assert!(!filtered.changed.contains(&root.join("ignored/file.ts")));
1416        assert!(filtered.changed.contains(&root.join("kept.ts")));
1417    }
1418
1419    #[test]
1420    fn ignore_rule_paths_are_control_only_for_external_excludes() {
1421        let tmp = TempDir::new().unwrap();
1422        let root = std::fs::canonicalize(tmp.path()).unwrap();
1423        let git_info = root.join(".git").join("info");
1424        std::fs::create_dir_all(&git_info).unwrap();
1425        let exclude = git_info.join("exclude");
1426        std::fs::write(&exclude, "ignored/\n").unwrap();
1427        let matcher = Arc::new(RwLock::new(None));
1428        let config = WatcherFilterConfig::new(root, None);
1429
1430        let filtered = filter_watcher_raw_paths_for_test(&config, &matcher, [exclude]);
1431
1432        assert!(filtered.ignore_file_changed);
1433        assert!(filtered.changed.is_empty());
1434    }
1435
1436    #[test]
1437    fn root_deleted_sends_control_and_exits() {
1438        let tmp = TempDir::new().unwrap();
1439        let root = std::fs::canonicalize(tmp.path()).unwrap();
1440        let matcher = Arc::new(RwLock::new(None));
1441        let generation = Arc::new(AtomicU64::new(0));
1442        let shutdown = Arc::new(AtomicBool::new(false));
1443        let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
1444        let (raw_tx, raw_rx) = mpsc::channel();
1445        let config = WatcherFilterConfig::new(root.clone(), None);
1446        let mut filter = WatcherFilterThread::new(
1447            config,
1448            matcher,
1449            generation,
1450            dispatch_tx,
1451            Arc::clone(&shutdown),
1452        );
1453        let handle = thread::spawn(move || filter.run(raw_rx));
1454        let _raw_tx = raw_tx;
1455        std::fs::remove_dir_all(&root).unwrap();
1456
1457        let event = dispatch_rx
1458            .recv_timeout(Duration::from_secs(2))
1459            .expect("root deleted event");
1460        assert_eq!(event, WatcherDispatchEvent::RootDeleted);
1461        shutdown.store(true, Ordering::SeqCst);
1462        handle.join().unwrap();
1463    }
1464}