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