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    BufferOverflow,
58    KernelDropped,
59    UserDropped,
60    Unknown,
61}
62
63impl RescanReason {
64    fn from_event_info(info: Option<&str>) -> Self {
65        match info {
66            Some("rescan: buffer overflow") => Self::BufferOverflow,
67            Some("rescan: kernel dropped") => Self::KernelDropped,
68            Some("rescan: user dropped") => Self::UserDropped,
69            _ => Self::Unknown,
70        }
71    }
72
73    pub(crate) fn as_str(self) -> &'static str {
74        match self {
75            Self::BufferOverflow => "buffer_overflow",
76            Self::KernelDropped => "kernel_dropped",
77            Self::UserDropped => "user_dropped",
78            Self::Unknown => "unknown",
79        }
80    }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum WatcherDispatchEvent {
85    Paths(Vec<PathBuf>),
86    RescanRequired(RescanReason),
87    IgnoreRulesChanged { path: PathBuf },
88    RootDeleted,
89    Error(String),
90}
91
92pub struct WatcherThreadHandle {
93    shutdown: Arc<AtomicBool>,
94    join: Option<JoinHandle<()>>,
95}
96
97/// Result of a bounded watcher-thread join.
98pub enum WatcherJoinOutcome {
99    Joined,
100    TimedOut(JoinHandle<()>),
101}
102
103impl WatcherThreadHandle {
104    pub fn new(shutdown: Arc<AtomicBool>, join: JoinHandle<()>) -> Self {
105        Self {
106            shutdown,
107            join: Some(join),
108        }
109    }
110
111    pub fn request_shutdown(&self) {
112        self.shutdown.store(true, Ordering::SeqCst);
113    }
114
115    pub fn is_finished(&self) -> bool {
116        self.join.as_ref().is_none_or(|join| join.is_finished())
117    }
118
119    pub fn shutdown_and_join(mut self) {
120        self.request_shutdown();
121        if let Some(join) = self.join.take() {
122            let _ = join.join();
123        }
124    }
125
126    /// Request shutdown and wait only up to `timeout` for the watcher thread.
127    /// The caller owns the still-live join handle on timeout and can monitor it
128    /// without blocking an executor or transport loop.
129    pub fn shutdown_and_join_timeout(mut self, timeout: Duration) -> WatcherJoinOutcome {
130        self.request_shutdown();
131        let Some(join) = self.join.take() else {
132            return WatcherJoinOutcome::Joined;
133        };
134        let deadline = Instant::now() + timeout;
135        while !join.is_finished() && Instant::now() < deadline {
136            thread::sleep(Duration::from_millis(10));
137        }
138        if join.is_finished() {
139            let _ = join.join();
140            WatcherJoinOutcome::Joined
141        } else {
142            WatcherJoinOutcome::TimedOut(join)
143        }
144    }
145}
146
147impl Drop for WatcherThreadHandle {
148    fn drop(&mut self) {
149        self.request_shutdown();
150    }
151}
152
153pub fn watcher_dispatch_channel() -> (Sender<WatcherDispatchEvent>, Receiver<WatcherDispatchEvent>)
154{
155    crossbeam_channel::bounded(WATCHER_DISPATCH_CHANNEL_CAPACITY)
156}
157
158/// Decide whether a `notify::Event` represents a real content change worth
159/// invalidating cached state for.
160pub fn watcher_event_invalidates(kind: &notify::EventKind) -> bool {
161    use notify::event::{MetadataKind, ModifyKind};
162    use notify::EventKind;
163    match kind {
164        EventKind::Create(_) | EventKind::Remove(_) => true,
165        EventKind::Modify(ModifyKind::Metadata(meta)) => !matches!(
166            meta,
167            MetadataKind::AccessTime
168                | MetadataKind::Permissions
169                | MetadataKind::Ownership
170                | MetadataKind::Extended
171        ),
172        EventKind::Modify(_) => true,
173        _ => false,
174    }
175}
176
177pub fn watcher_path_is_infra_skip(path: &Path) -> bool {
178    path.components().any(|c| {
179        matches!(c, Component::Normal(name) if matches!(
180            name.to_str().unwrap_or(""),
181            ".git" | ".opencode" | ".alfonso" | ".gsd" | "node_modules" | "target"
182        ))
183    })
184}
185
186/// High-churn ignored directories that can be dropped from the raw event stream
187/// before paying for a `realpath` canonicalization.
188///
189/// A build writes hundreds of thousands of files under `target/` (or
190/// `node_modules/` for JS installs); FSEvents delivers every one to AFT, and
191/// canonicalizing each just to drop it later in the filter pegs the
192/// single-threaded watcher loop. This is a pure path-component scan (no syscall),
193/// so the flood is rejected almost for free.
194///
195/// This deliberately omits `.git`: `.git/info/exclude` changes the corpus ignore
196/// set, and dropping `.git` here would hide them from the ignore-relevance check
197/// in the full filter. `.git` churn is small next to `target/`, so it stays on
198/// the canonicalizing path.
199fn watcher_path_is_high_churn_infra(path: &Path) -> bool {
200    path.components().any(|c| {
201        matches!(c, Component::Normal(name) if matches!(
202            name.to_str().unwrap_or(""),
203            ".opencode" | ".alfonso" | ".gsd" | "node_modules" | "target"
204        ))
205    })
206}
207
208fn watcher_path_is_ignore_file(path: &Path) -> bool {
209    path.file_name()
210        .map(|n| n == ".gitignore" || n == ".aftignore")
211        .unwrap_or(false)
212}
213
214fn watcher_same_path(path: &Path, target: &Path) -> bool {
215    if path == target {
216        return true;
217    }
218
219    std::fs::canonicalize(target)
220        .map(|target| path == target)
221        .unwrap_or(false)
222}
223
224fn watcher_path_is_git_head_metadata(config: &WatcherFilterConfig, path: &Path) -> bool {
225    crate::alias::capture_git_head_metadata(&config.project_root, config.git_common_dir.as_deref())
226        .is_ok_and(|metadata| metadata.matches_path(path))
227}
228
229fn watcher_path_is_git_info_exclude(config: &WatcherFilterConfig, path: &Path) -> bool {
230    watcher_same_path(path, &config.git_info_exclude_path())
231}
232
233fn watcher_path_is_global_gitignore(path: &Path) -> bool {
234    ignore::gitignore::gitconfig_excludes_path()
235        .as_deref()
236        .is_some_and(|global_ignore| watcher_same_path(path, global_ignore))
237}
238
239fn watcher_path_can_change_corpus_ignore(config: &WatcherFilterConfig, path: &Path) -> bool {
240    if watcher_path_is_global_gitignore(path) {
241        return true;
242    }
243    if watcher_path_is_git_info_exclude(config, path) {
244        return true;
245    }
246    if !path.starts_with(&config.project_root) {
247        return false;
248    }
249
250    watcher_path_is_ignore_file(path) && !watcher_path_is_infra_skip(path)
251}
252
253pub fn canonicalize_watcher_path(path: PathBuf) -> PathBuf {
254    if let Ok(canonical) = std::fs::canonicalize(&path) {
255        return canonical;
256    }
257
258    let parent = path.parent().map(Path::to_path_buf);
259    let file_name = path.file_name().map(std::ffi::OsStr::to_os_string);
260    match (parent, file_name) {
261        (Some(parent), Some(file_name)) => std::fs::canonicalize(parent)
262            .map(|canonical_parent| canonical_parent.join(file_name))
263            .unwrap_or(path),
264        _ => path,
265    }
266}
267
268pub(crate) fn watcher_path_is_ignored_by_matcher(matcher: &SharedGitignore, path: &Path) -> bool {
269    if watcher_path_is_infra_skip(path) {
270        return true;
271    }
272
273    let guard = matcher
274        .read()
275        .unwrap_or_else(|poisoned| poisoned.into_inner());
276    watcher_path_is_ignored(guard.as_deref(), path)
277}
278
279fn watcher_path_is_ignored(matcher: Option<&Gitignore>, path: &Path) -> bool {
280    matcher.is_some_and(|matcher| {
281        path.starts_with(matcher.path())
282            && matcher
283                .matched_path_or_any_parents(path, path.is_dir())
284                .is_ignore()
285    })
286}
287
288#[cfg(any(target_os = "macos", target_os = "linux", test))]
289const WATCHER_EXCLUSION_SEEDS: [&str; 17] = [
290    ".git",
291    "target",
292    "node_modules",
293    "dist",
294    "build",
295    ".next",
296    ".venv",
297    "venv",
298    "__pycache__",
299    ".turbo",
300    ".cache",
301    "coverage",
302    "out",
303    ".gradle",
304    ".dart_tool",
305    "Pods",
306    "DerivedData",
307];
308
309#[cfg(any(target_os = "macos", target_os = "linux", test))]
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub(crate) enum WatcherExclusionSource {
312    Seed,
313    Ranked,
314    Gitignore,
315}
316
317#[cfg(any(target_os = "macos", target_os = "linux", test))]
318impl WatcherExclusionSource {
319    fn priority(self) -> u8 {
320        match self {
321            Self::Seed => 0,
322            Self::Ranked => 1,
323            Self::Gitignore => 2,
324        }
325    }
326
327    #[cfg(any(target_os = "macos", target_os = "linux", test))]
328    pub(crate) fn as_str(self) -> &'static str {
329        match self {
330            Self::Seed => "seed",
331            Self::Ranked => "ranked",
332            Self::Gitignore => "gitignore",
333        }
334    }
335}
336
337#[cfg(any(target_os = "macos", target_os = "linux", test))]
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub(crate) struct WatcherExclusion {
340    path: PathBuf,
341    source: WatcherExclusionSource,
342}
343
344#[cfg(any(target_os = "macos", target_os = "linux", test))]
345impl WatcherExclusion {
346    pub(crate) fn path(&self) -> &Path {
347        &self.path
348    }
349
350    pub(crate) fn source(&self) -> WatcherExclusionSource {
351        self.source
352    }
353}
354
355#[cfg(any(target_os = "macos", target_os = "linux", test))]
356pub(crate) fn watcher_exclusion_paths(exclusions: &[WatcherExclusion]) -> Vec<PathBuf> {
357    exclusions
358        .iter()
359        .map(|exclusion| exclusion.path.clone())
360        .collect()
361}
362
363#[cfg(any(target_os = "macos", target_os = "linux", test))]
364#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
365struct GitignoreOrder {
366    source_priority: u8,
367    source_path: PathBuf,
368    line: usize,
369}
370
371#[cfg(any(target_os = "macos", target_os = "linux", test))]
372impl Default for GitignoreOrder {
373    fn default() -> Self {
374        Self {
375            source_priority: u8::MAX,
376            source_path: PathBuf::new(),
377            line: usize::MAX,
378        }
379    }
380}
381
382#[cfg(any(target_os = "macos", target_os = "linux", test))]
383impl GitignoreOrder {
384    fn for_glob(root: &Path, glob: &ignore::gitignore::Glob) -> Self {
385        let Some(source) = glob.from() else {
386            return Self::default();
387        };
388        let source_path = source
389            .strip_prefix(root)
390            .map(Path::to_path_buf)
391            .unwrap_or_else(|_| source.to_path_buf());
392        let source_priority = if !source.starts_with(root) {
393            0
394        } else if source == root.join(".gitignore") {
395            1
396        } else if source == root.join(".aftignore") {
397            2
398        } else if source.ends_with(Path::new("info/exclude")) {
399            3
400        } else {
401            4
402        };
403        let line = fs::read_to_string(source)
404            .ok()
405            .and_then(|contents| {
406                contents.lines().position(|line| {
407                    let normalized = if line.ends_with("\\ ") {
408                        line
409                    } else {
410                        line.trim_end()
411                    };
412                    normalized == glob.original()
413                })
414            })
415            .unwrap_or(usize::MAX);
416        Self {
417            source_priority,
418            source_path,
419            line,
420        }
421    }
422}
423
424#[cfg(any(target_os = "macos", target_os = "linux", test))]
425fn exclusion_seed_priority(relative: &Path, is_git: bool, is_ignored: bool) -> Option<usize> {
426    let mut components = relative.components();
427    let Component::Normal(name) = components.next()? else {
428        return None;
429    };
430    if components.next().is_some() {
431        return None;
432    }
433    let priority = WATCHER_EXCLUSION_SEEDS
434        .iter()
435        .position(|candidate| name == std::ffi::OsStr::new(candidate))?;
436    (is_git || is_ignored).then_some(priority)
437}
438
439/// Choose ignored directory boundaries that an OS watcher can omit entirely.
440///
441/// The eight backend slots are filled in three passes: existing root-level
442/// directories with a known high event volume, prefixes ranked by a previous
443/// overflow, then the remaining ignored boundaries in gitignore order. `.git`
444/// is eligible for the first seed slot only when it is a directory; linked
445/// worktrees use a `.git` file and must keep watching their root normally.
446#[cfg(any(target_os = "macos", target_os = "linux", test))]
447pub(crate) fn derive_excluded_subtrees(
448    root: &Path,
449    matcher: &SharedGitignore,
450    max_paths: Option<usize>,
451) -> Vec<WatcherExclusion> {
452    #[derive(Debug)]
453    struct Candidate {
454        path: PathBuf,
455        source: WatcherExclusionSource,
456        seed_priority: usize,
457        observed_count: u64,
458        gitignore_order: GitignoreOrder,
459    }
460
461    let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
462    let matcher = matcher
463        .read()
464        .unwrap_or_else(|poisoned| poisoned.into_inner())
465        .clone();
466    let observed = crate::context::watcher_counters_for_root(&root)
467        .observed_exclusion_prefixes()
468        .into_iter()
469        .map(|prefix| (PathBuf::from(prefix.prefix), prefix.count))
470        .collect::<BTreeMap<_, _>>();
471    let root_git = root.join(".git");
472    let mut candidates = Vec::<Candidate>::new();
473    let mut stack = vec![root.clone()];
474
475    while let Some(directory) = stack.pop() {
476        let Ok(entries) = fs::read_dir(&directory) else {
477            continue;
478        };
479        for entry in entries.flatten() {
480            let path = entry.path();
481            if !entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
482                continue;
483            }
484            let is_git = path == root_git;
485            let matched_glob = matcher.as_deref().and_then(|matcher| {
486                match matcher.matched_path_or_any_parents(&path, true) {
487                    ignore::Match::Ignore(glob) => Some(glob),
488                    ignore::Match::None | ignore::Match::Whitelist(_) => None,
489                }
490            });
491            let is_ignored = matched_glob.is_some();
492            if is_git || is_ignored {
493                let relative = path.strip_prefix(&root).unwrap_or(&path);
494                let observed_count = observed.get(relative).copied();
495                let seed_priority = exclusion_seed_priority(relative, is_git, is_ignored);
496                let source = if seed_priority.is_some() {
497                    WatcherExclusionSource::Seed
498                } else if observed_count.is_some() {
499                    WatcherExclusionSource::Ranked
500                } else {
501                    WatcherExclusionSource::Gitignore
502                };
503                candidates.push(Candidate {
504                    path,
505                    source,
506                    seed_priority: seed_priority.unwrap_or(usize::MAX),
507                    observed_count: observed_count.unwrap_or_default(),
508                    gitignore_order: matched_glob
509                        .map(|glob| GitignoreOrder::for_glob(&root, glob))
510                        .unwrap_or_default(),
511                });
512            } else {
513                stack.push(path);
514            }
515        }
516    }
517
518    candidates.sort_by(|left, right| {
519        left.source
520            .priority()
521            .cmp(&right.source.priority())
522            .then_with(|| match left.source {
523                WatcherExclusionSource::Seed => left.seed_priority.cmp(&right.seed_priority),
524                WatcherExclusionSource::Ranked => right
525                    .observed_count
526                    .cmp(&left.observed_count)
527                    .then_with(|| left.path.cmp(&right.path)),
528                WatcherExclusionSource::Gitignore => left
529                    .gitignore_order
530                    .cmp(&right.gitignore_order)
531                    .then_with(|| left.path.cmp(&right.path)),
532            })
533    });
534    let mut exclusions = candidates
535        .into_iter()
536        .map(|candidate| WatcherExclusion {
537            path: candidate.path,
538            source: candidate.source,
539        })
540        .collect::<Vec<_>>();
541    if let Some(max_paths) = max_paths {
542        exclusions.truncate(max_paths);
543    }
544    exclusions
545}
546
547const WATCHER_OBSERVATION_STATE_PREFIX: &str = "watcher.observed_exclusion_prefixes";
548
549/// Overflow rankings are shared by repository identity so a newly-created
550/// linked worktree starts with evidence learned by existing checkouts. We keep
551/// one repository ranking rather than a per-root override; the most recently
552/// persisted observation becomes the next bind's ranking for every sibling.
553fn watcher_observation_state_key(root: &Path) -> String {
554    let repository_key = crate::search_index::artifact_cache_key_memoized_only(root)
555        .unwrap_or_else(|| crate::search_index::artifact_cache_key(root));
556    format!("{WATCHER_OBSERVATION_STATE_PREFIX}:{repository_key}")
557}
558
559fn valid_observed_exclusion_prefix(prefix: &crate::context::WatcherOverflowPrefix) -> bool {
560    prefix.count > 0
561        && !prefix.prefix.is_empty()
562        && Path::new(&prefix.prefix)
563            .components()
564            .all(|component| matches!(component, Component::Normal(_)))
565}
566
567pub(crate) fn load_watcher_observations(
568    root: &Path,
569    counters: &crate::context::WatcherCounters,
570    db: Option<&Arc<Mutex<crate::db::TrackedConnection>>>,
571) {
572    let Some(db) = db else {
573        return;
574    };
575    let conn = db.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
576    let Ok(Some(raw)) =
577        crate::db::state::get_host_state(&conn, &watcher_observation_state_key(root))
578    else {
579        return;
580    };
581    let Ok(mut prefixes) = serde_json::from_str::<Vec<crate::context::WatcherOverflowPrefix>>(&raw)
582    else {
583        return;
584    };
585    prefixes.retain(valid_observed_exclusion_prefix);
586    prefixes.truncate(WATCHER_OBSERVED_EXCLUSION_LIMIT);
587    counters.set_observed_exclusion_prefixes(prefixes);
588}
589
590pub(crate) fn persist_watcher_observations(
591    root: &Path,
592    counters: &crate::context::WatcherCounters,
593    db: Option<&Arc<Mutex<crate::db::TrackedConnection>>>,
594) {
595    let Some(db) = db else {
596        return;
597    };
598    let prefixes = counters.observed_exclusion_prefixes();
599    let Ok(value) = serde_json::to_string(&prefixes) else {
600        return;
601    };
602    let now_ms = std::time::SystemTime::now()
603        .duration_since(std::time::UNIX_EPOCH)
604        .unwrap_or_default()
605        .as_millis()
606        .min(i64::MAX as u128) as i64;
607    let conn = db.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
608    if let Err(error) = crate::db::state::set_host_state(
609        &conn,
610        &watcher_observation_state_key(root),
611        &value,
612        now_ms,
613    ) {
614        crate::slog_warn!(
615            "failed to persist watcher overflow prefixes for {}: {}",
616            root.display(),
617            error
618        );
619    }
620}
621
622#[derive(Debug, Default, Clone, PartialEq, Eq)]
623pub struct FilteredWatcherPaths {
624    pub changed: BTreeSet<PathBuf>,
625    pub ignore_file_changed: bool,
626}
627
628fn filter_canonical_paths(
629    config: &WatcherFilterConfig,
630    matcher: &SharedGitignore,
631    raw_paths: BTreeSet<PathBuf>,
632) -> FilteredWatcherPaths {
633    let ignore_file_changed = raw_paths
634        .iter()
635        .any(|path| watcher_path_can_change_corpus_ignore(config, path));
636
637    let changed = raw_paths
638        .into_iter()
639        .filter(|path| {
640            if watcher_path_is_git_head_metadata(config, path) {
641                return true;
642            }
643            if watcher_path_is_infra_skip(path) {
644                return false;
645            }
646
647            if watcher_path_is_global_gitignore(path)
648                || watcher_path_is_git_info_exclude(config, path)
649            {
650                return false;
651            }
652
653            if watcher_path_is_ignored_by_matcher(matcher, path) {
654                return false;
655            }
656            true
657        })
658        .collect();
659
660    FilteredWatcherPaths {
661        changed,
662        ignore_file_changed,
663    }
664}
665
666pub fn filter_watcher_raw_paths_for_test<I>(
667    config: &WatcherFilterConfig,
668    matcher: &SharedGitignore,
669    raw_paths: I,
670) -> FilteredWatcherPaths
671where
672    I: IntoIterator<Item = PathBuf>,
673{
674    let raw_paths = raw_paths
675        .into_iter()
676        .map(canonicalize_watcher_path)
677        .collect::<BTreeSet<_>>();
678    filter_canonical_paths(config, matcher, raw_paths)
679}
680
681pub fn run_watcher_thread<W, E, F>(
682    config: WatcherFilterConfig,
683    extra_watch_paths: Vec<PathBuf>,
684    matcher: SharedGitignore,
685    matcher_generation: Arc<AtomicU64>,
686    dispatch_tx: Sender<WatcherDispatchEvent>,
687    shutdown: Arc<AtomicBool>,
688    attach: F,
689) where
690    W: Send + 'static,
691    E: std::fmt::Display,
692    F: FnOnce(PathBuf, Vec<PathBuf>, mpsc::Sender<notify::Result<notify::Event>>) -> Result<W, E>,
693{
694    let (raw_tx, raw_rx) = mpsc::channel();
695    let root_path = config.project_root.clone();
696    match attach(root_path.clone(), extra_watch_paths, raw_tx) {
697        Ok(_watcher) => {
698            if shutdown.load(Ordering::SeqCst) {
699                return;
700            }
701            crate::slog_info!("watcher started: {}", root_path.display());
702            let mut filter = WatcherFilterThread::new(
703                config,
704                matcher,
705                matcher_generation,
706                dispatch_tx,
707                shutdown,
708            );
709            filter.run(raw_rx);
710        }
711        Err(error) => {
712            if !shutdown.load(Ordering::SeqCst) {
713                log::debug!(
714                    "watcher init failed: {} — callers will work with stale data",
715                    error
716                );
717                let _ = dispatch_tx.send(WatcherDispatchEvent::Error(format!(
718                    "watcher init failed: {error}"
719                )));
720            }
721        }
722    }
723}
724
725struct WatcherFilterThread {
726    config: WatcherFilterConfig,
727    matcher: SharedGitignore,
728    matcher_generation: Arc<AtomicU64>,
729    dispatch_tx: Sender<WatcherDispatchEvent>,
730    shutdown: Arc<AtomicBool>,
731    raw_paths: BTreeSet<PathBuf>,
732    recent_paths: VecDeque<(PathBuf, Instant)>,
733    flush_deadline: Option<Instant>,
734}
735
736impl WatcherFilterThread {
737    fn new(
738        config: WatcherFilterConfig,
739        matcher: SharedGitignore,
740        matcher_generation: Arc<AtomicU64>,
741        dispatch_tx: Sender<WatcherDispatchEvent>,
742        shutdown: Arc<AtomicBool>,
743    ) -> Self {
744        Self {
745            config,
746            matcher,
747            matcher_generation,
748            dispatch_tx,
749            shutdown,
750            raw_paths: BTreeSet::new(),
751            recent_paths: VecDeque::with_capacity(WATCHER_ATTRIBUTION_RING_CAPACITY),
752            flush_deadline: None,
753        }
754    }
755
756    fn run(&mut self, raw_rx: mpsc::Receiver<notify::Result<notify::Event>>) {
757        loop {
758            if self.shutdown.load(Ordering::SeqCst) {
759                self.flush_pending();
760                return;
761            }
762            if self.project_root_was_deleted() {
763                self.raw_paths.clear();
764                let _ = self.send_dispatch(WatcherDispatchEvent::RootDeleted);
765                return;
766            }
767            if self.flush_deadline_reached() {
768                if !self.flush_pending() {
769                    return;
770                }
771                continue;
772            }
773
774            match raw_rx.recv_timeout(self.next_recv_timeout()) {
775                Ok(Ok(event)) => {
776                    self.config.counters.note_raw_event();
777                    if event.need_rescan() {
778                        let reason = RescanReason::from_event_info(event.info());
779                        let during_rescan = self.log_overflow(reason);
780                        self.raw_paths.clear();
781                        self.flush_deadline = None;
782                        if !during_rescan
783                            && !self.send_dispatch(WatcherDispatchEvent::RescanRequired(reason))
784                        {
785                            return;
786                        }
787                        continue;
788                    }
789                    self.record_recent_paths(&event.paths);
790                    if watcher_event_invalidates(&event.kind) {
791                        self.config.counters.note_invalidating_event();
792                        if !self.push_raw_paths(event.paths) {
793                            return;
794                        }
795                    }
796                }
797                Ok(Err(error)) => {
798                    let _ = self.send_dispatch(WatcherDispatchEvent::Error(error.to_string()));
799                    return;
800                }
801                Err(mpsc::RecvTimeoutError::Timeout) => {
802                    if !self.flush_pending() {
803                        return;
804                    }
805                }
806                Err(mpsc::RecvTimeoutError::Disconnected) => {
807                    if !self.shutdown.load(Ordering::SeqCst) {
808                        let _ = self.send_dispatch(WatcherDispatchEvent::Error(
809                            "watcher channel disconnected".to_string(),
810                        ));
811                    }
812                    return;
813                }
814            }
815        }
816    }
817
818    fn project_root_was_deleted(&self) -> bool {
819        !self.config.project_root.exists()
820    }
821
822    fn record_recent_paths(&mut self, paths: &[PathBuf]) {
823        let arrived_at = Instant::now();
824        for path in paths {
825            let relative = path
826                .strip_prefix(&self.config.project_root)
827                .map(Path::to_path_buf)
828                .unwrap_or_else(|_| {
829                    PathBuf::from("<external>")
830                        .join(path.file_name().unwrap_or_else(|| path.as_os_str()))
831                });
832            if self.recent_paths.len() == WATCHER_ATTRIBUTION_RING_CAPACITY {
833                self.recent_paths.pop_front();
834            }
835            self.recent_paths.push_back((relative, arrived_at));
836        }
837    }
838
839    fn overflow_prefixes(&self) -> Vec<crate::context::WatcherOverflowPrefix> {
840        let mut counts = BTreeMap::<String, u64>::new();
841        for (path, _) in &self.recent_paths {
842            // Root-relative, first two components, joined with `/` on every
843            // platform: the prefix is rendered in the overflow log line (one
844            // grammar for the fleet's log readers) and persisted for slot
845            // ranking, and `Path::new` on Windows reads `/` back as a
846            // separator when the prefix is turned into an exclusion path.
847            let prefix = path
848                .components()
849                .filter_map(|component| match component {
850                    Component::Normal(name) => Some(name.to_string_lossy()),
851                    _ => None,
852                })
853                .take(2)
854                .collect::<Vec<_>>()
855                .join("/");
856            if prefix.is_empty() {
857                continue;
858            }
859            *counts.entry(prefix).or_default() += 1;
860        }
861        let mut prefixes = counts
862            .into_iter()
863            .map(|(prefix, count)| crate::context::WatcherOverflowPrefix { prefix, count })
864            .collect::<Vec<_>>();
865        prefixes.sort_by(|left, right| {
866            right
867                .count
868                .cmp(&left.count)
869                .then_with(|| left.prefix.cmp(&right.prefix))
870        });
871        prefixes.truncate(WATCHER_OVERFLOW_PREFIX_LIMIT);
872        prefixes
873    }
874
875    fn observed_exclusion_prefixes(&self) -> Vec<crate::context::WatcherOverflowPrefix> {
876        let matcher = self
877            .matcher
878            .read()
879            .unwrap_or_else(std::sync::PoisonError::into_inner)
880            .clone();
881        let root_git = self.config.project_root.join(".git");
882        let mut counts = BTreeMap::<String, u64>::new();
883        for (path, _) in &self.recent_paths {
884            let mut relative = PathBuf::new();
885            let mut absolute = self.config.project_root.clone();
886            for component in path.components() {
887                let Component::Normal(name) = component else {
888                    continue;
889                };
890                relative.push(name);
891                absolute.push(name);
892                if absolute == root_git || watcher_path_is_ignored(matcher.as_deref(), &absolute) {
893                    *counts
894                        .entry(relative.to_string_lossy().into_owned())
895                        .or_default() += 1;
896                    break;
897                }
898            }
899        }
900        let mut prefixes = counts
901            .into_iter()
902            .map(|(prefix, count)| crate::context::WatcherOverflowPrefix { prefix, count })
903            .collect::<Vec<_>>();
904        prefixes.sort_by(|left, right| {
905            right
906                .count
907                .cmp(&left.count)
908                .then_with(|| left.prefix.cmp(&right.prefix))
909        });
910        prefixes.truncate(WATCHER_OBSERVED_EXCLUSION_LIMIT);
911        prefixes
912    }
913
914    fn log_overflow(&self, reason: RescanReason) -> bool {
915        let prefixes = self.overflow_prefixes();
916        self.config
917            .counters
918            .set_observed_exclusion_prefixes(self.observed_exclusion_prefixes());
919        let during_rescan = self.config.counters.note_overflow(reason, prefixes.clone());
920        let backend = self.config.counters.backend_exclusions();
921        let exclusions = backend
922            .paths
923            .iter()
924            .map(|path| {
925                path.strip_prefix(&self.config.project_root)
926                    .unwrap_or(path)
927                    .display()
928                    .to_string()
929            })
930            .collect::<Vec<_>>()
931            .join(",");
932        let prefixes = prefixes
933            .iter()
934            .map(|prefix| format!("{}:{}", prefix.prefix, prefix.count))
935            .collect::<Vec<_>>()
936            .join(",");
937        let span_ms = self
938            .recent_paths
939            .front()
940            .zip(self.recent_paths.back())
941            .map(|((_, first), (_, last))| {
942                last.saturating_duration_since(*first)
943                    .as_millis()
944                    .min(u64::MAX as u128) as u64
945            })
946            .unwrap_or(0);
947        let queue_depth = backend
948            .queue_depth
949            .map(|depth| depth.to_string())
950            .unwrap_or_else(|| "unavailable".to_string());
951        let line = format!(
952            "watcher overflow: reason={} root={} exclusions=[{}] matcher_generation={} top_prefixes=[{}] ring_span_ms={} queue_depth={} rescan_in_progress={}",
953            reason.as_str(),
954            self.config.project_root.display(),
955            exclusions,
956            backend.matcher_generation,
957            prefixes,
958            span_ms,
959            queue_depth,
960            during_rescan
961        );
962        emit_watcher_overflow_log(line);
963        during_rescan
964    }
965
966    fn push_raw_paths(&mut self, paths: Vec<PathBuf>) -> bool {
967        for path in paths {
968            // Drop high-churn ignored dirs (target/, node_modules/, agent infra)
969            // on the RAW path before canonicalizing. `canonicalize_watcher_path`
970            // is a realpath syscall; a build floods FSEvents with hundreds of
971            // thousands of target/ paths, and paying a syscall per path only to
972            // discard them later pegged this single watcher thread. The full
973            // filter still drops these (watcher_path_is_infra_skip), so this is a
974            // pure perf short-circuit with no behavior change.
975            if watcher_path_is_high_churn_infra(&path) {
976                continue;
977            }
978            // Canonicalize at intake so the set keys (and downstream consumers)
979            // see normalized paths — this is what collapses macOS /var ->
980            // /private/var aliasing and matches the callgraph/semantic/search
981            // cache keys. Same-file repeats within the window still dedup here;
982            // the high-churn flood is already dropped above, before this syscall.
983            self.raw_paths.insert(canonicalize_watcher_path(path));
984        }
985        if !self.raw_paths.is_empty() && self.flush_deadline.is_none() {
986            self.flush_deadline = Some(Instant::now() + WATCHER_FLUSH_WINDOW);
987        }
988        if self.raw_paths.len() >= WATCHER_MAX_BATCH_PATHS {
989            return self.flush_pending();
990        }
991        true
992    }
993
994    fn next_recv_timeout(&self) -> Duration {
995        let root_check = ROOT_DELETED_CHECK_INTERVAL;
996        match self.flush_deadline {
997            Some(deadline) => deadline
998                .saturating_duration_since(Instant::now())
999                .min(root_check),
1000            None => root_check,
1001        }
1002    }
1003
1004    fn flush_deadline_reached(&self) -> bool {
1005        self.flush_deadline
1006            .is_some_and(|deadline| Instant::now() >= deadline)
1007    }
1008
1009    fn flush_pending(&mut self) -> bool {
1010        if self.raw_paths.is_empty() {
1011            self.flush_deadline = None;
1012            return true;
1013        }
1014
1015        let raw_paths = std::mem::take(&mut self.raw_paths);
1016        self.flush_deadline = None;
1017        let ignore_path = raw_paths
1018            .iter()
1019            .find(|path| watcher_path_can_change_corpus_ignore(&self.config, path))
1020            .cloned();
1021        let ignore_file_changed = ignore_path.is_some();
1022        if let Some(path) = ignore_path {
1023            let observed_generation = self.matcher_generation.load(Ordering::SeqCst);
1024            if !self.send_dispatch(WatcherDispatchEvent::IgnoreRulesChanged { path }) {
1025                return false;
1026            }
1027            if !self.wait_for_gitignore_rebuild(observed_generation) {
1028                return false;
1029            }
1030        }
1031
1032        let filtered = filter_canonical_paths(&self.config, &self.matcher, raw_paths);
1033        debug_assert_eq!(filtered.ignore_file_changed, ignore_file_changed);
1034        self.config
1035            .counters
1036            .note_paths_after_gitignore(filtered.changed.len());
1037        if filtered.changed.is_empty() {
1038            return true;
1039        }
1040        let paths = filtered.changed.into_iter().collect::<Vec<_>>();
1041        let path_count = paths.len();
1042        if !self.send_dispatch(WatcherDispatchEvent::Paths(paths)) {
1043            return false;
1044        }
1045        self.config.counters.note_paths_dispatched(path_count);
1046        true
1047    }
1048
1049    fn wait_for_gitignore_rebuild(&self, observed_generation: u64) -> bool {
1050        while !self.shutdown.load(Ordering::SeqCst)
1051            && self.matcher_generation.load(Ordering::SeqCst) == observed_generation
1052        {
1053            if self.project_root_was_deleted() {
1054                let _ = self.send_dispatch(WatcherDispatchEvent::RootDeleted);
1055                return false;
1056            }
1057            thread::sleep(GITIGNORE_REBUILD_POLL_INTERVAL);
1058        }
1059        !self.shutdown.load(Ordering::SeqCst)
1060    }
1061
1062    fn send_dispatch(&self, event: WatcherDispatchEvent) -> bool {
1063        let mut event = event;
1064        loop {
1065            match self
1066                .dispatch_tx
1067                .send_timeout(event, DISPATCH_SEND_POLL_INTERVAL)
1068            {
1069                Ok(()) => return true,
1070                Err(SendTimeoutError::Timeout(returned)) => {
1071                    if self.shutdown.load(Ordering::SeqCst) {
1072                        return false;
1073                    }
1074                    event = returned;
1075                }
1076                Err(SendTimeoutError::Disconnected(_)) => return false,
1077            }
1078        }
1079    }
1080}
1081
1082fn emit_watcher_overflow_log(line: String) {
1083    crate::slog_warn!("{line}");
1084    #[cfg(test)]
1085    WATCHER_OVERFLOW_LOGS_FOR_TEST
1086        .get_or_init(|| Mutex::new(Vec::new()))
1087        .lock()
1088        .unwrap_or_else(std::sync::PoisonError::into_inner)
1089        .push(line);
1090}
1091
1092#[cfg(test)]
1093static WATCHER_OVERFLOW_LOGS_FOR_TEST: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
1094
1095#[cfg(test)]
1096pub(crate) fn take_watcher_overflow_logs_for_test() -> Vec<String> {
1097    std::mem::take(
1098        &mut *WATCHER_OVERFLOW_LOGS_FOR_TEST
1099            .get_or_init(|| Mutex::new(Vec::new()))
1100            .lock()
1101            .unwrap_or_else(std::sync::PoisonError::into_inner),
1102    )
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107    use super::*;
1108    use ignore::gitignore::GitignoreBuilder;
1109    use notify::event::{
1110        AccessKind, AccessMode, CreateKind, DataChange, Flag, MetadataKind, ModifyKind,
1111    };
1112    use notify::EventKind;
1113    use std::process::Command;
1114    use tempfile::TempDir;
1115
1116    fn shared_matcher(root: &Path) -> SharedGitignore {
1117        let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1118        let mut builder = GitignoreBuilder::new(&root);
1119        let ignore = root.join(".gitignore");
1120        if ignore.exists() {
1121            if let Some(error) = builder.add(&ignore) {
1122                panic!("gitignore parse error: {error}");
1123            }
1124        }
1125        let matcher = builder.build().unwrap();
1126        let matcher = (matcher.num_ignores() > 0).then(|| Arc::new(matcher));
1127        Arc::new(RwLock::new(matcher))
1128    }
1129
1130    fn run_git(root: &Path, args: &[&str]) {
1131        assert!(
1132            Command::new("git")
1133                .current_dir(root)
1134                .args(args)
1135                .status()
1136                .expect("run git")
1137                .success(),
1138            "git {args:?} failed in {}",
1139            root.display()
1140        );
1141    }
1142
1143    #[test]
1144    fn overflow_volume_promotes_deep_ignored_prefix_into_next_exclusion_set() {
1145        let root = TempDir::new().unwrap();
1146        std::fs::create_dir(root.path().join(".git")).unwrap();
1147        let fallback = [
1148            "target",
1149            "node_modules",
1150            "dist",
1151            "build",
1152            ".next",
1153            "tmp",
1154            ".bench",
1155            "coverage",
1156            "aaa",
1157            "bbb",
1158        ];
1159        for directory in fallback {
1160            std::fs::create_dir_all(root.path().join(directory)).unwrap();
1161        }
1162        let hot = root.path().join("packages/opencode-plugin/tmp");
1163        std::fs::create_dir_all(&hot).unwrap();
1164        std::fs::write(
1165            root.path().join(".gitignore"),
1166            format!(
1167                "{}packages/*/tmp/\n",
1168                fallback
1169                    .iter()
1170                    .map(|directory| format!("{directory}/\n"))
1171                    .collect::<String>()
1172            ),
1173        )
1174        .unwrap();
1175        let canonical_root = std::fs::canonicalize(root.path()).unwrap();
1176        let hot = std::fs::canonicalize(hot).unwrap();
1177        let matcher = shared_matcher(&canonical_root);
1178        let generation = Arc::new(AtomicU64::new(4));
1179        let shutdown = Arc::new(AtomicBool::new(false));
1180        let (dispatch_tx, dispatch_rx) = crossbeam_channel::bounded(1);
1181        let (raw_tx, raw_rx) = mpsc::channel();
1182        let config = WatcherFilterConfig::new(canonical_root.clone(), None);
1183        let mut filter = WatcherFilterThread::new(
1184            config,
1185            Arc::clone(&matcher),
1186            generation,
1187            dispatch_tx,
1188            Arc::clone(&shutdown),
1189        );
1190        let handle = thread::spawn(move || filter.run(raw_rx));
1191
1192        for index in 0..64 {
1193            raw_tx
1194                .send(Ok(notify::Event::new(EventKind::Create(CreateKind::File))
1195                    .add_path(hot.join(format!("host-install-{index}")))))
1196                .unwrap();
1197        }
1198        raw_tx
1199            .send(Ok(
1200                notify::Event::new(EventKind::Other).set_flag(Flag::Rescan)
1201            ))
1202            .unwrap();
1203        assert_eq!(
1204            dispatch_rx.recv_timeout(Duration::from_secs(2)).unwrap(),
1205            WatcherDispatchEvent::RescanRequired(RescanReason::Unknown)
1206        );
1207        shutdown.store(true, Ordering::SeqCst);
1208        drop(raw_tx);
1209        handle.join().unwrap();
1210
1211        let exclusions =
1212            derive_excluded_subtrees(&canonical_root, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1213        assert_eq!(exclusions[0].path(), canonical_root.join(".git"));
1214        assert_eq!(exclusions.last().unwrap().path(), hot);
1215        assert_eq!(
1216            exclusions.last().unwrap().source(),
1217            WatcherExclusionSource::Ranked
1218        );
1219    }
1220
1221    #[test]
1222    fn observed_exclusion_ranking_survives_state_database_reload() {
1223        let root = TempDir::new().unwrap();
1224        let storage = TempDir::new().unwrap();
1225        std::fs::create_dir(root.path().join(".git")).unwrap();
1226        let hot = root.path().join("packages/opencode-plugin/tmp");
1227        std::fs::create_dir_all(&hot).unwrap();
1228        let fallback = [
1229            "target",
1230            "node_modules",
1231            "dist",
1232            "build",
1233            ".next",
1234            "tmp",
1235            ".bench",
1236            "coverage",
1237        ];
1238        for directory in fallback {
1239            std::fs::create_dir(root.path().join(directory)).unwrap();
1240        }
1241        std::fs::write(
1242            root.path().join(".gitignore"),
1243            format!(
1244                "{}packages/*/tmp/\n",
1245                fallback
1246                    .iter()
1247                    .map(|directory| format!("{directory}/\n"))
1248                    .collect::<String>()
1249            ),
1250        )
1251        .unwrap();
1252        let canonical_root = std::fs::canonicalize(root.path()).unwrap();
1253        let hot = std::fs::canonicalize(hot).unwrap();
1254        let matcher = shared_matcher(&canonical_root);
1255        let counters = crate::context::watcher_counters_for_root(&canonical_root);
1256        let db = Arc::new(Mutex::new(
1257            crate::db::open(&storage.path().join("aft.db")).unwrap(),
1258        ));
1259        counters.set_observed_exclusion_prefixes(vec![crate::context::WatcherOverflowPrefix {
1260            prefix: "packages/opencode-plugin/tmp".to_string(),
1261            count: 37,
1262        }]);
1263        persist_watcher_observations(&canonical_root, &counters, Some(&db));
1264        counters.set_observed_exclusion_prefixes(Vec::new());
1265
1266        load_watcher_observations(&canonical_root, &counters, Some(&db));
1267
1268        assert_eq!(
1269            counters.observed_exclusion_prefixes(),
1270            vec![crate::context::WatcherOverflowPrefix {
1271                prefix: "packages/opencode-plugin/tmp".to_string(),
1272                count: 37,
1273            }]
1274        );
1275        let exclusions =
1276            derive_excluded_subtrees(&canonical_root, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1277        assert_eq!(exclusions[0].path(), canonical_root.join(".git"));
1278        assert_eq!(exclusions.last().unwrap().path(), hot);
1279        assert_eq!(
1280            exclusions.last().unwrap().source(),
1281            WatcherExclusionSource::Ranked
1282        );
1283    }
1284
1285    #[test]
1286    fn sibling_worktree_inherits_repository_ranking_on_first_bind() {
1287        let container = TempDir::new().unwrap();
1288        let storage = TempDir::new().unwrap();
1289        let main = container.path().join("main");
1290        let sibling = container.path().join("sibling");
1291        std::fs::create_dir(&main).unwrap();
1292        run_git(&main, &["init"]);
1293        std::fs::write(main.join(".gitignore"), "packages/*/tmp/\n").unwrap();
1294        std::fs::write(main.join("tracked.txt"), "repository identity\n").unwrap();
1295        run_git(&main, &["add", "."]);
1296        run_git(
1297            &main,
1298            &[
1299                "-c",
1300                "user.name=AFT Test",
1301                "-c",
1302                "user.email=aft@example.invalid",
1303                "commit",
1304                "-m",
1305                "fixture",
1306            ],
1307        );
1308        run_git(
1309            &main,
1310            &[
1311                "worktree",
1312                "add",
1313                "-b",
1314                "watcher-sibling",
1315                sibling.to_str().unwrap(),
1316            ],
1317        );
1318
1319        let main = std::fs::canonicalize(main).unwrap();
1320        let sibling = std::fs::canonicalize(sibling).unwrap();
1321        let relative_hot = "packages/opencode-plugin/tmp";
1322        std::fs::create_dir_all(main.join(relative_hot)).unwrap();
1323        std::fs::create_dir_all(sibling.join(relative_hot)).unwrap();
1324        assert_ne!(
1325            crate::path_identity::project_scope_key(&main),
1326            crate::path_identity::project_scope_key(&sibling)
1327        );
1328        assert_eq!(
1329            crate::search_index::artifact_cache_key(&main),
1330            crate::search_index::artifact_cache_key(&sibling)
1331        );
1332
1333        let db = Arc::new(Mutex::new(
1334            crate::db::open(&storage.path().join("aft.db")).unwrap(),
1335        ));
1336        let main_counters = crate::context::watcher_counters_for_root(&main);
1337        main_counters.set_observed_exclusion_prefixes(vec![
1338            crate::context::WatcherOverflowPrefix {
1339                prefix: relative_hot.to_string(),
1340                count: 91,
1341            },
1342        ]);
1343        persist_watcher_observations(&main, &main_counters, Some(&db));
1344
1345        let sibling_counters = crate::context::watcher_counters_for_root(&sibling);
1346        sibling_counters.set_observed_exclusion_prefixes(Vec::new());
1347        load_watcher_observations(&sibling, &sibling_counters, Some(&db));
1348
1349        assert_eq!(
1350            sibling_counters.observed_exclusion_prefixes(),
1351            vec![crate::context::WatcherOverflowPrefix {
1352                prefix: relative_hot.to_string(),
1353                count: 91,
1354            }]
1355        );
1356        let matcher = shared_matcher(&sibling);
1357        let exclusions =
1358            derive_excluded_subtrees(&sibling, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1359        assert_eq!(exclusions[0].path(), sibling.join(relative_hot));
1360        assert_eq!(exclusions[0].source(), WatcherExclusionSource::Ranked);
1361    }
1362
1363    #[test]
1364    fn exclusion_derivation_uses_fixed_priority_caps_and_skips_missing_directories() {
1365        let root = TempDir::new().unwrap();
1366        std::fs::create_dir(root.path().join(".git")).unwrap();
1367        let priorities = [
1368            "target",
1369            "node_modules",
1370            "dist",
1371            "build",
1372            ".next",
1373            ".venv",
1374            "venv",
1375            "__pycache__",
1376        ];
1377        for name in priorities {
1378            std::fs::create_dir(root.path().join(name)).unwrap();
1379        }
1380        std::fs::create_dir(root.path().join("other-generated")).unwrap();
1381        std::fs::write(
1382            root.path().join(".gitignore"),
1383            format!(
1384                "{}other-generated/\nmissing/\n",
1385                priorities
1386                    .iter()
1387                    .rev()
1388                    .map(|name| format!("{name}/\n"))
1389                    .collect::<String>()
1390            ),
1391        )
1392        .unwrap();
1393        let matcher = shared_matcher(root.path());
1394
1395        let exclusions =
1396            derive_excluded_subtrees(root.path(), &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1397
1398        assert_eq!(exclusions.len(), WATCHER_EXCLUSION_LIMIT);
1399        assert_eq!(
1400            exclusions[0].path(),
1401            std::fs::canonicalize(root.path().join(".git")).unwrap()
1402        );
1403        assert_eq!(
1404            watcher_exclusion_paths(&exclusions[1..]),
1405            priorities[..WATCHER_EXCLUSION_LIMIT - 1]
1406                .iter()
1407                .map(|name| std::fs::canonicalize(root.path().join(name)).unwrap())
1408                .collect::<Vec<_>>()
1409        );
1410        assert!(!exclusions
1411            .iter()
1412            .any(|exclusion| exclusion.path().ends_with("missing")));
1413        assert!(!exclusions
1414            .iter()
1415            .any(|exclusion| exclusion.path().ends_with("other-generated")));
1416    }
1417
1418    #[test]
1419    fn fresh_root_seeds_heavy_directories_before_late_gitignore_patterns() {
1420        let root = TempDir::new().unwrap();
1421        std::fs::create_dir(root.path().join(".git")).unwrap();
1422        let patterns = [
1423            "generated-01",
1424            "generated-02",
1425            "generated-03",
1426            "generated-04",
1427            "generated-05",
1428            "generated-06",
1429            "generated-07",
1430            "generated-08",
1431            "generated-09",
1432            "build",
1433            "target",
1434            "node_modules",
1435        ];
1436        for pattern in patterns {
1437            std::fs::create_dir(root.path().join(pattern)).unwrap();
1438        }
1439        std::fs::write(
1440            root.path().join(".gitignore"),
1441            patterns
1442                .iter()
1443                .map(|pattern| format!("{pattern}/\n"))
1444                .collect::<String>(),
1445        )
1446        .unwrap();
1447        let canonical_root = std::fs::canonicalize(root.path()).unwrap();
1448        let matcher = shared_matcher(&canonical_root);
1449
1450        let exclusions =
1451            derive_excluded_subtrees(&canonical_root, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1452
1453        assert_eq!(exclusions.len(), WATCHER_EXCLUSION_LIMIT);
1454        assert_eq!(
1455            watcher_exclusion_paths(&exclusions[..4]),
1456            [".git", "target", "node_modules", "build"]
1457                .iter()
1458                .map(|name| canonical_root.join(name))
1459                .collect::<Vec<_>>()
1460        );
1461        assert!(exclusions[..4]
1462            .iter()
1463            .all(|exclusion| exclusion.source() == WatcherExclusionSource::Seed));
1464        assert_eq!(
1465            watcher_exclusion_paths(&exclusions[4..]),
1466            patterns[..4]
1467                .iter()
1468                .map(|name| canonical_root.join(name))
1469                .collect::<Vec<_>>()
1470        );
1471        assert!(exclusions[4..]
1472            .iter()
1473            .all(|exclusion| exclusion.source() == WatcherExclusionSource::Gitignore));
1474
1475        let source_root = TempDir::new().unwrap();
1476        std::fs::create_dir(source_root.path().join(".git")).unwrap();
1477        std::fs::create_dir(source_root.path().join("build")).unwrap();
1478        std::fs::create_dir(source_root.path().join("ignored")).unwrap();
1479        std::fs::write(source_root.path().join(".gitignore"), "ignored/\n").unwrap();
1480        let source_root = std::fs::canonicalize(source_root.path()).unwrap();
1481        let matcher = shared_matcher(&source_root);
1482        let exclusions =
1483            derive_excluded_subtrees(&source_root, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1484        assert!(!exclusions
1485            .iter()
1486            .any(|exclusion| exclusion.path() == source_root.join("build")));
1487    }
1488
1489    #[test]
1490    fn event_kind_filter_accepts_content_changes_only() {
1491        assert!(watcher_event_invalidates(&EventKind::Create(
1492            CreateKind::File
1493        )));
1494        assert!(watcher_event_invalidates(&EventKind::Modify(
1495            ModifyKind::Data(DataChange::Content)
1496        )));
1497        assert!(watcher_event_invalidates(&EventKind::Modify(
1498            ModifyKind::Metadata(MetadataKind::WriteTime)
1499        )));
1500        assert!(!watcher_event_invalidates(&EventKind::Modify(
1501            ModifyKind::Metadata(MetadataKind::AccessTime)
1502        )));
1503        assert!(!watcher_event_invalidates(&EventKind::Modify(
1504            ModifyKind::Metadata(MetadataKind::Permissions)
1505        )));
1506        assert!(!watcher_event_invalidates(&EventKind::Access(
1507            AccessKind::Open(AccessMode::Read)
1508        )));
1509        assert!(!watcher_event_invalidates(&EventKind::Other));
1510    }
1511
1512    #[test]
1513    fn high_churn_infra_skip_drops_build_dirs_but_keeps_git_and_source() {
1514        // target/ and node_modules/ are dropped on the raw path before the
1515        // realpath syscall — this is the build-flood short-circuit.
1516        assert!(watcher_path_is_high_churn_infra(Path::new(
1517            "/proj/target/debug/deps/foo.o"
1518        )));
1519        assert!(watcher_path_is_high_churn_infra(Path::new(
1520            "/proj/node_modules/.bin/x"
1521        )));
1522        assert!(watcher_path_is_high_churn_infra(Path::new(
1523            "/proj/.alfonso/notes/x"
1524        )));
1525        // .git is deliberately NOT high-churn-skipped: .git/info/exclude must
1526        // still reach the ignore-relevance check.
1527        assert!(!watcher_path_is_high_churn_infra(Path::new(
1528            "/proj/.git/info/exclude"
1529        )));
1530        // Source files always pass through to canonicalization + filtering.
1531        assert!(!watcher_path_is_high_churn_infra(Path::new(
1532            "/proj/src/main.rs"
1533        )));
1534        // The full filter still drops .git (and everything high-churn does).
1535        assert!(watcher_path_is_infra_skip(Path::new("/proj/.git/index")));
1536    }
1537
1538    #[test]
1539    fn git_head_and_resolved_ref_bypass_git_infra_filter() {
1540        let tmp = TempDir::new().unwrap();
1541        let root = std::fs::canonicalize(tmp.path()).unwrap();
1542        let git = root.join(".git");
1543        let head = git.join("HEAD");
1544        let resolved_ref = git.join("refs/heads/main");
1545        std::fs::create_dir_all(resolved_ref.parent().unwrap()).unwrap();
1546        std::fs::write(&head, "ref: refs/heads/main\n").unwrap();
1547        std::fs::write(&resolved_ref, "0000000000000000000000000000000000000000\n").unwrap();
1548        std::fs::write(git.join("index"), []).unwrap();
1549        let config = WatcherFilterConfig::new(root.clone(), None);
1550        let matcher = shared_matcher(&root);
1551
1552        let filtered = filter_watcher_raw_paths_for_test(
1553            &config,
1554            &matcher,
1555            [head.clone(), resolved_ref.clone(), git.join("index")],
1556        );
1557
1558        assert_eq!(filtered.changed, BTreeSet::from([head, resolved_ref]));
1559    }
1560
1561    #[test]
1562    fn rescan_event_dispatches_control_and_supersedes_pending_paths() {
1563        let tmp = TempDir::new().unwrap();
1564        let root = std::fs::canonicalize(tmp.path()).unwrap();
1565        let pending = root.join("pending.rs");
1566        std::fs::write(&pending, "fn main() {}\n").unwrap();
1567        let matcher = Arc::new(RwLock::new(None));
1568        let generation = Arc::new(AtomicU64::new(0));
1569        let shutdown = Arc::new(AtomicBool::new(false));
1570        let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
1571        let (raw_tx, raw_rx) = mpsc::channel();
1572        let config = WatcherFilterConfig::new(root, None);
1573        let counters = Arc::clone(&config.counters);
1574        let mut filter = WatcherFilterThread::new(
1575            config,
1576            matcher,
1577            generation,
1578            dispatch_tx,
1579            Arc::clone(&shutdown),
1580        );
1581        let handle = thread::spawn(move || filter.run(raw_rx));
1582
1583        let mut granular = notify::Event::new(EventKind::Create(CreateKind::File));
1584        granular.paths.push(pending);
1585        raw_tx.send(Ok(granular)).unwrap();
1586        for (info, expected) in [
1587            (
1588                Some("rescan: buffer overflow"),
1589                RescanReason::BufferOverflow,
1590            ),
1591            (Some("rescan: kernel dropped"), RescanReason::KernelDropped),
1592            (Some("rescan: user dropped"), RescanReason::UserDropped),
1593            (None, RescanReason::Unknown),
1594        ] {
1595            let mut event = notify::Event::new(EventKind::Other).set_flag(Flag::Rescan);
1596            if let Some(info) = info {
1597                event = event.set_info(info);
1598            }
1599            raw_tx.send(Ok(event)).unwrap();
1600            assert_eq!(
1601                dispatch_rx
1602                    .recv_timeout(Duration::from_secs(2))
1603                    .expect("rescan event"),
1604                WatcherDispatchEvent::RescanRequired(expected)
1605            );
1606        }
1607        assert!(
1608            dispatch_rx
1609                .recv_timeout(WATCHER_FLUSH_WINDOW + Duration::from_millis(100))
1610                .is_err(),
1611            "pending granular paths should be cleared by a rescan signal"
1612        );
1613        let snapshot = counters.snapshot();
1614        assert_eq!(snapshot.raw_events_total, 5);
1615        assert_eq!(snapshot.invalidating_events_total, 1);
1616        assert_eq!(snapshot.paths_after_gitignore_total, 0);
1617        assert_eq!(snapshot.paths_dispatched_total, 0);
1618
1619        shutdown.store(true, Ordering::SeqCst);
1620        drop(raw_tx);
1621        handle.join().unwrap();
1622    }
1623
1624    #[test]
1625    fn overflow_log_attributes_excluded_and_nonexcluded_burst_prefixes() {
1626        let tmp = TempDir::new().unwrap();
1627        let root = std::fs::canonicalize(tmp.path()).unwrap();
1628        let target = root.join("target/cache");
1629        let source = root.join("src/generated");
1630        std::fs::create_dir_all(&target).unwrap();
1631        std::fs::create_dir_all(&source).unwrap();
1632        std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
1633        let matcher = shared_matcher(&root);
1634        let generation = Arc::new(AtomicU64::new(7));
1635        let shutdown = Arc::new(AtomicBool::new(false));
1636        let (dispatch_tx, dispatch_rx) = crossbeam_channel::bounded(1);
1637        let (raw_tx, raw_rx) = mpsc::channel();
1638        let config = WatcherFilterConfig::new(root.clone(), None);
1639        config.counters.set_backend_exclusions(
1640            7,
1641            (0..WATCHER_EXCLUSION_LIMIT)
1642                .map(|index| root.join(format!("excluded-{index}")))
1643                .collect(),
1644        );
1645        let counters = Arc::clone(&config.counters);
1646        let mut filter = WatcherFilterThread::new(
1647            config,
1648            matcher,
1649            generation,
1650            dispatch_tx,
1651            Arc::clone(&shutdown),
1652        );
1653        let handle = thread::spawn(move || filter.run(raw_rx));
1654
1655        for index in 0..20 {
1656            raw_tx
1657                .send(Ok(notify::Event::new(EventKind::Create(CreateKind::File))
1658                    .add_path(target.join(format!("artifact-{index}")))))
1659                .unwrap();
1660        }
1661        for index in 0..7 {
1662            raw_tx
1663                .send(Ok(notify::Event::new(EventKind::Create(CreateKind::File))
1664                    .add_path(source.join(format!("source-{index}.rs")))))
1665                .unwrap();
1666        }
1667        raw_tx
1668            .send(Ok(
1669                notify::Event::new(EventKind::Other).set_flag(Flag::Rescan)
1670            ))
1671            .unwrap();
1672        assert_eq!(
1673            dispatch_rx.recv_timeout(Duration::from_secs(2)).unwrap(),
1674            WatcherDispatchEvent::RescanRequired(RescanReason::Unknown)
1675        );
1676
1677        shutdown.store(true, Ordering::SeqCst);
1678        drop(raw_tx);
1679        handle.join().unwrap();
1680
1681        let lines = take_watcher_overflow_logs_for_test();
1682        let line = lines
1683            .iter()
1684            .find(|line| line.contains(&format!("root={}", root.display())))
1685            .unwrap_or_else(|| panic!("missing overflow line for {}: {lines:?}", root.display()));
1686        assert!(line.contains("matcher_generation=7"), "line: {line}");
1687        assert!(line.contains("target/cache:20"), "line: {line}");
1688        assert!(line.contains("src/generated:7"), "line: {line}");
1689        assert!(line.contains("queue_depth=unavailable"), "line: {line}");
1690        assert!(line.contains("rescan_in_progress=false"), "line: {line}");
1691        for index in 0..WATCHER_EXCLUSION_LIMIT {
1692            assert!(line.contains(&format!("excluded-{index}")), "line: {line}");
1693        }
1694        let snapshot = counters.snapshot();
1695        assert_eq!(snapshot.overflows_total, 1);
1696        assert_eq!(snapshot.overflows_during_rescan, 0);
1697        assert_eq!(snapshot.last_overflow_prefixes[0].prefix, "target/cache");
1698        assert_eq!(snapshot.last_overflow_prefixes[0].count, 20);
1699    }
1700
1701    #[test]
1702    fn watcher_thread_records_filter_pipeline_counters() {
1703        let tmp = TempDir::new().unwrap();
1704        let root = std::fs::canonicalize(tmp.path()).unwrap();
1705        let changed = root.join("changed.rs");
1706        std::fs::write(&changed, "fn changed() {}\n").unwrap();
1707        let matcher = Arc::new(RwLock::new(None));
1708        let generation = Arc::new(AtomicU64::new(0));
1709        let shutdown = Arc::new(AtomicBool::new(false));
1710        let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
1711        let (raw_tx, raw_rx) = mpsc::channel();
1712        let config = WatcherFilterConfig::new(root, None);
1713        let counters = Arc::clone(&config.counters);
1714        let mut filter = WatcherFilterThread::new(
1715            config,
1716            matcher,
1717            generation,
1718            dispatch_tx,
1719            Arc::clone(&shutdown),
1720        );
1721        let handle = thread::spawn(move || filter.run(raw_rx));
1722
1723        let mut event = notify::Event::new(EventKind::Create(CreateKind::File));
1724        event.paths.push(changed.clone());
1725        raw_tx.send(Ok(event)).unwrap();
1726        assert_eq!(
1727            dispatch_rx
1728                .recv_timeout(Duration::from_secs(2))
1729                .expect("filtered paths"),
1730            WatcherDispatchEvent::Paths(vec![changed])
1731        );
1732
1733        shutdown.store(true, Ordering::SeqCst);
1734        drop(raw_tx);
1735        handle.join().unwrap();
1736
1737        let snapshot = counters.snapshot();
1738        assert_eq!(snapshot.raw_events_total, 1);
1739        assert_eq!(snapshot.raw_events_since_last_rescan, 1);
1740        assert_eq!(snapshot.invalidating_events_total, 1);
1741        assert_eq!(snapshot.invalidating_events_since_last_rescan, 1);
1742        assert_eq!(snapshot.paths_after_gitignore_total, 1);
1743        assert_eq!(snapshot.paths_after_gitignore_since_last_rescan, 1);
1744        assert_eq!(snapshot.paths_dispatched_total, 1);
1745        assert_eq!(snapshot.paths_dispatched_since_last_rescan, 1);
1746    }
1747
1748    #[test]
1749    fn configured_context_and_filter_thread_share_root_counters() {
1750        let tmp = TempDir::new().unwrap();
1751        let root = std::fs::canonicalize(tmp.path()).unwrap();
1752        let ctx = crate::context::AppContext::new(
1753            crate::context::default_language_provider_factory(),
1754            crate::config::Config::default(),
1755        );
1756        ctx.update_config(|config| config.project_root = Some(root.clone()));
1757        let config = WatcherFilterConfig::new(root, None);
1758
1759        config.counters.note_raw_event();
1760
1761        assert_eq!(ctx.watcher_counters().snapshot().raw_events_total, 1);
1762    }
1763
1764    #[test]
1765    fn filters_gitignored_paths_with_shared_matcher() {
1766        let tmp = TempDir::new().unwrap();
1767        let root = std::fs::canonicalize(tmp.path()).unwrap();
1768        std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
1769        std::fs::create_dir_all(root.join("ignored")).unwrap();
1770        std::fs::write(root.join("ignored/file.ts"), "ignored").unwrap();
1771        std::fs::write(root.join("kept.ts"), "kept").unwrap();
1772        let matcher = shared_matcher(&root);
1773        let config = WatcherFilterConfig::new(root.clone(), None);
1774
1775        let filtered = filter_watcher_raw_paths_for_test(
1776            &config,
1777            &matcher,
1778            [root.join("ignored/file.ts"), root.join("kept.ts")],
1779        );
1780
1781        assert!(!filtered.changed.contains(&root.join("ignored/file.ts")));
1782        assert!(filtered.changed.contains(&root.join("kept.ts")));
1783    }
1784
1785    #[test]
1786    fn ignore_rule_paths_are_control_only_for_external_excludes() {
1787        let tmp = TempDir::new().unwrap();
1788        let root = std::fs::canonicalize(tmp.path()).unwrap();
1789        let git_info = root.join(".git").join("info");
1790        std::fs::create_dir_all(&git_info).unwrap();
1791        let exclude = git_info.join("exclude");
1792        std::fs::write(&exclude, "ignored/\n").unwrap();
1793        let matcher = Arc::new(RwLock::new(None));
1794        let config = WatcherFilterConfig::new(root, None);
1795
1796        let filtered = filter_watcher_raw_paths_for_test(&config, &matcher, [exclude]);
1797
1798        assert!(filtered.ignore_file_changed);
1799        assert!(filtered.changed.is_empty());
1800    }
1801
1802    #[test]
1803    fn root_deleted_sends_control_and_exits() {
1804        let tmp = TempDir::new().unwrap();
1805        let root = std::fs::canonicalize(tmp.path()).unwrap();
1806        let matcher = Arc::new(RwLock::new(None));
1807        let generation = Arc::new(AtomicU64::new(0));
1808        let shutdown = Arc::new(AtomicBool::new(false));
1809        let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
1810        let (raw_tx, raw_rx) = mpsc::channel();
1811        let config = WatcherFilterConfig::new(root.clone(), None);
1812        let mut filter = WatcherFilterThread::new(
1813            config,
1814            matcher,
1815            generation,
1816            dispatch_tx,
1817            Arc::clone(&shutdown),
1818        );
1819        let handle = thread::spawn(move || filter.run(raw_rx));
1820        let _raw_tx = raw_tx;
1821        std::fs::remove_dir_all(&root).unwrap();
1822
1823        let event = dispatch_rx
1824            .recv_timeout(Duration::from_secs(2))
1825            .expect("root deleted event");
1826        assert_eq!(event, WatcherDispatchEvent::RootDeleted);
1827        shutdown.store(true, Ordering::SeqCst);
1828        handle.join().unwrap();
1829    }
1830}