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
94pub 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 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
155pub fn watcher_event_invalidates(kind: ¬ify::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
183fn 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#[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 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 if watcher_path_is_high_churn_infra(&path) {
810 continue;
811 }
812 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 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 assert!(!watcher_path_is_high_churn_infra(Path::new(
1189 "/proj/.git/info/exclude"
1190 )));
1191 assert!(!watcher_path_is_high_churn_infra(Path::new(
1193 "/proj/src/main.rs"
1194 )));
1195 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}