1use std::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
16use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
17use std::time::Duration;
18
19use super::is_content_change;
20
21const SCAN_BATCH_SIZE: usize = 256;
22const WATCH_POLL_INTERVAL: Duration = Duration::from_millis(100);
23const WATCH_BATCH_CAP: usize = 1024;
26
27#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
28pub struct FileCandidate {
29 pub rel_path: String,
30 pub rel_path_lower: String,
31 pub basename_lower: String,
32 pub depth: usize,
33}
34
35pub enum FileIndexChange {
36 Upsert(FileCandidate),
37 RemoveExact { rel_path: String },
38 RemovePrefix { rel_prefix: String },
39 ReplacePrefix { rel_prefix: String, entries: Vec<FileCandidate> },
40}
41
42pub enum ScanProgress {
45 Batch(Vec<FileCandidate>),
46 Finished,
47}
48
49pub enum WatchProgress {
52 Changes(Vec<FileIndexChange>),
53 Rebuild,
54}
55
56pub struct CancelToken(Arc<AtomicBool>);
59
60impl Drop for CancelToken {
61 fn drop(&mut self) {
62 self.0.store(true, AtomicOrdering::Relaxed);
63 }
64}
65
66pub fn start_scan(root: PathBuf, respect_gitignore: bool) -> (Receiver<ScanProgress>, CancelToken) {
71 let cancel = Arc::new(AtomicBool::new(false));
72 let cancel_clone = Arc::clone(&cancel);
73 let (tx, rx) = mpsc::channel::<ScanProgress>();
74 std::thread::spawn(move || {
75 let mut batch = Vec::with_capacity(SCAN_BATCH_SIZE);
76 let mut emit_candidate = |candidate| {
77 batch.push(candidate);
78 if batch.len() < SCAN_BATCH_SIZE {
79 return true;
80 }
81 tx.send(ScanProgress::Batch(std::mem::take(&mut batch))).is_ok()
82 };
83 if !for_each_candidate(
84 &root,
85 &root,
86 respect_gitignore,
87 Some(&cancel_clone),
88 &mut emit_candidate,
89 ) {
90 return;
91 }
92 if !batch.is_empty() && tx.send(ScanProgress::Batch(batch)).is_err() {
93 return;
94 }
95 let _ = tx.send(ScanProgress::Finished);
96 });
97 (rx, CancelToken(cancel))
98}
99
100pub fn start_watch(
104 root: PathBuf,
105 respect_gitignore: bool,
106) -> (Receiver<WatchProgress>, CancelToken) {
107 let cancel = Arc::new(AtomicBool::new(false));
108 let cancel_clone = Arc::clone(&cancel);
109 let (tx, rx) = mpsc::channel::<WatchProgress>();
110 std::thread::spawn(move || {
111 let (watch_tx, watch_rx) = mpsc::channel();
112 let mut watcher = match notify::recommended_watcher(move |result| {
113 let _ = watch_tx.send(result);
114 }) {
115 Ok(watcher) => watcher,
116 Err(err) => {
117 tracing::warn!(target: "forge_agent::env::file_index", %err, "watcher setup failed");
118 return;
119 }
120 };
121 if let Err(err) =
122 notify::Watcher::watch(&mut watcher, &root, notify::RecursiveMode::Recursive)
123 {
124 tracing::warn!(target: "forge_agent::env::file_index", %err, "watcher start failed");
125 return;
126 }
127
128 let mut filter = WatchFilter::new(&root, respect_gitignore);
129 while !cancel_clone.load(AtomicOrdering::Relaxed) {
130 let first = match watch_rx.recv_timeout(WATCH_POLL_INTERVAL) {
131 Ok(event) => event,
132 Err(RecvTimeoutError::Timeout) => continue,
133 Err(RecvTimeoutError::Disconnected) => break,
134 };
135 let mut batch = vec![first];
140 while let Ok(event) = watch_rx.try_recv() {
141 batch.push(event);
142 if batch.len() >= WATCH_BATCH_CAP {
143 break;
144 }
145 }
146
147 let mut changes = Vec::new();
148 let mut rebuild = false;
149 for event in batch {
150 match event {
151 Ok(event) => {
152 match classify_watch_event(&root, respect_gitignore, &filter, &event) {
153 Some(WatchProgress::Rebuild) => rebuild = true,
154 Some(WatchProgress::Changes(mut batch_changes)) => {
155 changes.append(&mut batch_changes);
156 }
157 None => {}
158 }
159 }
160 Err(err) => {
161 tracing::warn!(target: "forge_agent::env::file_index", %err, "watcher event failed");
162 rebuild = true;
163 }
164 }
165 }
166
167 if rebuild {
168 filter = WatchFilter::new(&root, respect_gitignore);
171 if tx.send(WatchProgress::Rebuild).is_err() {
172 break;
173 }
174 continue;
175 }
176 if !changes.is_empty() && tx.send(WatchProgress::Changes(changes)).is_err() {
177 break;
178 }
179 }
180 });
181 (rx, CancelToken(cancel))
182}
183
184pub fn start_change_watch(root: PathBuf, respect_gitignore: bool) -> (Receiver<()>, CancelToken) {
193 let cancel = Arc::new(AtomicBool::new(false));
194 let cancel_clone = Arc::clone(&cancel);
195 let (tx, rx) = mpsc::channel::<()>();
196 std::thread::spawn(move || {
197 let (watch_tx, watch_rx) = mpsc::channel();
198 let mut watcher = match notify::recommended_watcher(move |result| {
199 let _ = watch_tx.send(result);
200 }) {
201 Ok(watcher) => watcher,
202 Err(err) => {
203 tracing::warn!(
204 target: "forge_agent::env::file_index",
205 %err,
206 "change watch setup failed",
207 );
208 return;
209 }
210 };
211 if let Err(err) =
212 notify::Watcher::watch(&mut watcher, &root, notify::RecursiveMode::Recursive)
213 {
214 tracing::warn!(target: "forge_agent::env::file_index", %err, "change watch start failed");
215 return;
216 }
217
218 let mut filter = WatchFilter::new(&root, respect_gitignore);
219 while !cancel_clone.load(AtomicOrdering::Relaxed) {
220 let first = match watch_rx.recv_timeout(WATCH_POLL_INTERVAL) {
221 Ok(event) => event,
222 Err(RecvTimeoutError::Timeout) => continue,
223 Err(RecvTimeoutError::Disconnected) => break,
224 };
225 let mut batch = vec![first];
228 while let Ok(event) = watch_rx.try_recv() {
229 batch.push(event);
230 if batch.len() >= WATCH_BATCH_CAP {
231 break;
232 }
233 }
234
235 let mut moved = false;
236 for event in batch {
237 match event {
238 Ok(event) => {
239 if is_content_change(event.kind)
240 && matches_ignore_semantics_change(&root, &event.paths)
241 {
242 filter = WatchFilter::new(&root, respect_gitignore);
245 moved = true;
246 continue;
247 }
248 if event.paths.iter().any(|path| !filter.is_ignored(&root, path)) {
249 moved = true;
250 }
251 }
252 Err(_) => moved = true,
256 }
257 }
258 if moved && tx.send(()).is_err() {
259 break;
260 }
261 }
262 });
263 (rx, CancelToken(cancel))
264}
265
266pub fn collect_candidates(
269 root: &Path,
270 walk_root: &Path,
271 respect_gitignore: bool,
272) -> Vec<FileCandidate> {
273 let mut candidates = Vec::new();
274 for_each_candidate(root, walk_root, respect_gitignore, None, &mut |candidate| {
275 candidates.push(candidate);
276 true
277 });
278 candidates
279}
280
281fn is_under_git(rel: &Path) -> bool {
284 rel.components().next().is_some_and(|first| first.as_os_str() == ".git")
285}
286
287fn for_each_candidate(
292 root: &Path,
293 walk_root: &Path,
294 respect_gitignore: bool,
295 cancel: Option<&Arc<AtomicBool>>,
296 emit: &mut impl FnMut(FileCandidate) -> bool,
297) -> bool {
298 let mut builder = ignore::WalkBuilder::new(walk_root);
299 let filter_root = root.to_path_buf();
300 builder
301 .hidden(false)
302 .git_ignore(respect_gitignore)
303 .git_global(respect_gitignore)
304 .git_exclude(respect_gitignore)
305 .filter_entry(move |entry| {
306 entry.path().strip_prefix(&filter_root).map_or(true, |rel| !is_under_git(rel))
307 })
308 .sort_by_file_path(std::cmp::Ord::cmp);
309
310 for result in builder.build() {
311 if cancel.is_some_and(|flag| flag.load(AtomicOrdering::Relaxed)) {
312 return false;
313 }
314 let Ok(entry) = result else { continue };
315 let Some(candidate) = candidate_from_entry(root, &entry) else { continue };
316 if !emit(candidate) {
317 return false;
318 }
319 }
320 true
321}
322
323fn candidate_from_entry(root: &Path, entry: &ignore::DirEntry) -> Option<FileCandidate> {
324 let ft = entry.file_type()?;
325 let is_dir = ft.is_dir();
326 let is_file = ft.is_file();
327 if !is_dir && !is_file {
328 return None;
329 }
330 let path = entry.path();
331 let rel = path.strip_prefix(root).ok()?;
332 let rel_str = rel.to_string_lossy().replace('\\', "/");
333 if rel_str.is_empty() {
334 return None;
335 }
336 let depth = rel_str.matches('/').count();
337 let rel_path = if is_dir { format!("{rel_str}/") } else { rel_str };
338 let rel_path_lower = rel_path.to_lowercase();
339 let basename_lower = candidate_basename(&rel_path).to_lowercase();
340 Some(FileCandidate { rel_path, rel_path_lower, basename_lower, depth })
341}
342
343pub fn candidate_basename(rel_path: &str) -> &str {
344 let trimmed = rel_path.trim_end_matches('/');
345 trimmed.rsplit('/').next().unwrap_or(trimmed)
346}
347
348fn normalize_relative_path(root: &Path, path: &Path) -> Option<String> {
349 let rel = path.strip_prefix(root).ok()?;
350 let rel_str = rel.to_string_lossy().replace('\\', "/");
351 (!rel_str.is_empty()).then_some(rel_str)
352}
353
354fn normalized_prefix(root: &Path, path: &Path) -> Option<String> {
355 normalize_relative_path(root, path).map(ensure_dir_suffix)
356}
357
358fn ensure_dir_suffix(mut rel_path: String) -> String {
359 if !rel_path.ends_with('/') {
360 rel_path.push('/');
361 }
362 rel_path
363}
364
365pub(crate) struct WatchFilter {
376 matcher: Option<ignore::gitignore::Gitignore>,
377 canonical_root: PathBuf,
385}
386
387impl WatchFilter {
388 pub(crate) fn new(root: &Path, respect_gitignore: bool) -> Self {
389 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
390 if !respect_gitignore {
391 return Self { matcher: None, canonical_root };
392 }
393 let mut builder = ignore::gitignore::GitignoreBuilder::new(&canonical_root);
394 for candidate in [".gitignore", ".ignore"] {
395 builder.add(root.join(candidate));
396 }
397 builder.add(root.join(".git").join("info").join("exclude"));
398 Self { matcher: builder.build().ok(), canonical_root }
399 }
400
401 fn is_ignored(&self, root: &Path, path: &Path) -> bool {
402 let Ok(rel) = path.strip_prefix(root).or_else(|_| path.strip_prefix(&self.canonical_root))
403 else {
404 return false;
405 };
406 if is_under_git(rel) {
407 return true;
408 }
409 let Some(matcher) = self.matcher.as_ref() else { return false };
410 let rebased = self.canonical_root.join(rel);
415 matcher.matched_path_or_any_parents(&rebased, rebased.is_dir()).is_ignore()
416 }
417}
418
419fn classify_watch_event(
420 root: &Path,
421 respect_gitignore: bool,
422 filter: &WatchFilter,
423 event: ¬ify::Event,
424) -> Option<WatchProgress> {
425 use notify::event::{CreateKind, EventKind, ModifyKind, RemoveKind, RenameMode};
426
427 if is_content_change(event.kind) && matches_ignore_semantics_change(root, &event.paths) {
428 return Some(WatchProgress::Rebuild);
429 }
430
431 let paths: Vec<PathBuf> =
432 event.paths.iter().filter(|path| !filter.is_ignored(root, path)).cloned().collect();
433 if paths.is_empty() {
434 return None;
435 }
436
437 let changes = match event.kind {
438 EventKind::Modify(ModifyKind::Name(RenameMode::Any | RenameMode::Both)) => {
439 collect_rename_changes(root, respect_gitignore, &paths)
440 }
441 EventKind::Create(CreateKind::Any | CreateKind::File | CreateKind::Folder)
442 | EventKind::Modify(
443 ModifyKind::Any
444 | ModifyKind::Data(_)
445 | ModifyKind::Metadata(_)
446 | ModifyKind::Name(RenameMode::To),
447 ) => collect_create_or_modify_changes(root, respect_gitignore, &paths),
448 EventKind::Modify(ModifyKind::Name(RenameMode::From))
449 | EventKind::Remove(RemoveKind::Any | RemoveKind::File | RemoveKind::Folder) => {
450 collect_remove_changes(root, &paths)
451 }
452 EventKind::Other => return Some(WatchProgress::Rebuild),
453 _ => Vec::new(),
454 };
455
456 (!changes.is_empty()).then_some(WatchProgress::Changes(changes))
457}
458
459fn matches_ignore_semantics_change(root: &Path, paths: &[PathBuf]) -> bool {
460 paths.iter().any(|path| {
461 let Some(rel) = normalize_relative_path(root, path) else {
462 return false;
463 };
464 rel == ".gitignore"
465 || rel == ".ignore"
466 || rel.ends_with("/.gitignore")
467 || rel.ends_with("/.ignore")
468 }) || paths.iter().any(|path| {
469 path.file_name().is_some_and(|name| name == "exclude")
470 && path.parent().and_then(Path::file_name).is_some_and(|name| name == "info")
471 && path
472 .parent()
473 .and_then(Path::parent)
474 .and_then(Path::file_name)
475 .is_some_and(|name| name == ".git")
476 })
477}
478
479fn collect_create_or_modify_changes(
480 root: &Path,
481 respect_gitignore: bool,
482 paths: &[PathBuf],
483) -> Vec<FileIndexChange> {
484 let mut changes = Vec::new();
485 for path in paths {
486 if path.is_dir() {
487 if let Some(change) = replace_subtree_change(root, path, respect_gitignore) {
488 changes.push(change);
489 }
490 } else if path.is_file() {
491 let mut entries = scan_subtree(root, path, respect_gitignore);
492 if let Some(candidate) = entries.pop() {
493 changes.push(FileIndexChange::Upsert(candidate));
494 } else if let Some(rel_path) = normalize_relative_path(root, path) {
495 changes.push(FileIndexChange::RemoveExact { rel_path });
496 }
497 }
498 }
499 changes
500}
501
502fn collect_remove_changes(root: &Path, paths: &[PathBuf]) -> Vec<FileIndexChange> {
503 let mut changes = Vec::new();
504 for path in paths {
505 let Some(rel_path) = normalize_relative_path(root, path) else {
506 continue;
507 };
508 changes.push(FileIndexChange::RemoveExact { rel_path: rel_path.clone() });
509 changes.push(FileIndexChange::RemovePrefix { rel_prefix: ensure_dir_suffix(rel_path) });
510 }
511 changes
512}
513
514fn collect_rename_changes(
515 root: &Path,
516 respect_gitignore: bool,
517 paths: &[PathBuf],
518) -> Vec<FileIndexChange> {
519 if paths.len() < 2 {
520 if paths.first().is_some_and(|p| !p.exists()) {
525 return collect_remove_changes(root, paths);
526 }
527 return collect_parent_rescan_changes(root, respect_gitignore, paths);
528 }
529 collect_parent_rescan_changes(root, respect_gitignore, paths)
530}
531
532fn scan_subtree(root: &Path, path: &Path, respect_gitignore: bool) -> Vec<FileCandidate> {
533 collect_candidates(root, path, respect_gitignore)
534}
535
536fn collect_parent_rescan_changes(
537 root: &Path,
538 respect_gitignore: bool,
539 paths: &[PathBuf],
540) -> Vec<FileIndexChange> {
541 let mut changes = Vec::new();
542 let mut seen_prefixes = BTreeSet::new();
543 for path in paths {
544 let Some(parent) = path.parent() else { continue };
545 let Some(change) = replace_subtree_change(root, parent, respect_gitignore) else {
546 continue;
547 };
548 let FileIndexChange::ReplacePrefix { rel_prefix, .. } = &change else {
549 continue;
550 };
551 if seen_prefixes.insert(rel_prefix.clone()) {
552 changes.push(change);
553 }
554 }
555 changes
556}
557
558fn replace_subtree_change(
559 root: &Path,
560 path: &Path,
561 respect_gitignore: bool,
562) -> Option<FileIndexChange> {
563 let rel_prefix = if path == root { String::new() } else { normalized_prefix(root, path)? };
564 let entries = scan_subtree(root, path, respect_gitignore);
565 Some(FileIndexChange::ReplacePrefix { rel_prefix, entries })
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use notify::event::{CreateKind, EventKind};
572
573 fn touch(path: &Path) {
574 if let Some(parent) = path.parent() {
575 std::fs::create_dir_all(parent).unwrap();
576 }
577 std::fs::write(path, b"x").unwrap();
578 }
579
580 fn create_event(path: &Path) -> notify::Event {
581 notify::Event {
582 kind: EventKind::Create(CreateKind::File),
583 paths: vec![path.to_path_buf()],
584 attrs: notify::event::EventAttributes::new(),
585 }
586 }
587
588 fn fixture() -> tempfile::TempDir {
589 let dir = tempfile::TempDir::new().unwrap();
590 let root = dir.path();
591 std::fs::write(root.join(".gitignore"), "/target\n/node_modules\n").unwrap();
592 touch(&root.join("src/main.rs"));
593 touch(&root.join("target/debug/liba.rlib"));
594 dir
595 }
596
597 #[test]
602 fn every_path_under_git_is_filtered_out() {
603 let dir = fixture();
604 let root = dir.path();
605 let filter = WatchFilter::new(root, true);
606
607 for under in [".git/index", ".git/refs/heads/main", ".git/HEAD"] {
608 assert!(
609 filter.is_ignored(root, &root.join(under)),
610 "{under} must not reach a watcher's caller",
611 );
612 }
613 assert!(
614 !filter.is_ignored(root, &root.join("src/main.rs")),
615 "and a tracked path is the control: it is not filtered",
616 );
617 }
618
619 #[test]
623 fn the_walk_does_not_index_git_internals() {
624 let dir = fixture();
625 let root = dir.path();
626 touch(&root.join(".git/config"));
627 touch(&root.join(".git/refs/heads/main"));
628
629 for respect_gitignore in [true, false] {
630 let candidates = collect_candidates(root, root, respect_gitignore);
631 let paths: Vec<&str> =
632 candidates.iter().map(|candidate| candidate.rel_path.as_str()).collect();
633 assert!(
634 !paths.iter().any(|path| path.starts_with(".git/")),
635 ".git is not the tree's ({respect_gitignore}): {paths:?}",
636 );
637 assert!(
638 paths.contains(&"src/main.rs"),
639 "and a real file still lists, which is the control ({respect_gitignore}): {paths:?}",
640 );
641 }
642 }
643
644 #[test]
648 fn a_nested_checkouts_git_survives_a_subtree_walk() {
649 let dir = fixture();
650 let root = dir.path();
651 touch(&root.join("vendor/dep/.git/config"));
652 touch(&root.join("vendor/dep/main.rs"));
653
654 let candidates = collect_candidates(root, &root.join("vendor/dep"), true);
655 let paths: Vec<&str> =
656 candidates.iter().map(|candidate| candidate.rel_path.as_str()).collect();
657
658 assert!(paths.contains(&"vendor/dep/.git/config"), "a nested checkout stays: {paths:?}");
659 assert!(
660 paths.contains(&"vendor/dep/main.rs"),
661 "and a real file beside it lists, which is the control: {paths:?}",
662 );
663 }
664
665 const WEDGE: Duration = Duration::from_secs(100);
670
671 #[test]
678 fn the_change_watch_reports_a_write() {
679 let dir = fixture();
680 let root = dir.path();
681 let (rx, _cancel) = start_change_watch(root.to_path_buf(), true);
682
683 let stop = Arc::new(AtomicBool::new(false));
684 let writer = {
685 let stop = Arc::clone(&stop);
686 let root = root.to_path_buf();
687 std::thread::spawn(move || {
688 let mut attempt = 0;
689 while !stop.load(AtomicOrdering::Relaxed) {
690 touch(&root.join(format!("src/write-{attempt}.rs")));
691 attempt += 1;
692 std::thread::sleep(Duration::from_millis(50));
693 }
694 })
695 };
696 let reported = rx.recv_timeout(WEDGE);
697 stop.store(true, AtomicOrdering::Relaxed);
698 let _ = writer.join();
699
700 match reported {
701 Ok(()) => {}
702 Err(RecvTimeoutError::Timeout) => panic!(
703 "a write to the watched tree was swallowed: nothing was reported within {WEDGE:?}"
704 ),
705 Err(RecvTimeoutError::Disconnected) => {
706 panic!("the change watch ended without reporting the write")
707 }
708 }
709 }
710
711 #[test]
712 fn an_event_inside_a_gitignored_directory_is_dropped() {
713 let dir = fixture();
720 let root = dir.path();
721 let filter = WatchFilter::new(root, true);
722 let event = create_event(&root.join("target/debug/liba.rlib"));
723 assert!(classify_watch_event(root, true, &filter, &event).is_none());
724 }
725
726 #[test]
727 fn an_event_on_a_tracked_file_still_classifies() {
728 let dir = fixture();
729 let root = dir.path();
730 let filter = WatchFilter::new(root, true);
731 let event = create_event(&root.join("src/main.rs"));
732 assert!(matches!(
733 classify_watch_event(root, true, &filter, &event),
734 Some(WatchProgress::Changes(_))
735 ));
736 }
737
738 #[test]
739 fn ignoring_is_off_when_gitignore_is_not_respected() {
740 let dir = fixture();
743 let root = dir.path();
744 let filter = WatchFilter::new(root, false);
745 let event = create_event(&root.join("target/debug/liba.rlib"));
746 assert!(classify_watch_event(root, false, &filter, &event).is_some());
747 }
748
749 #[test]
750 fn a_gitignore_edit_still_forces_a_rebuild() {
751 let dir = fixture();
754 let root = dir.path();
755 let filter = WatchFilter::new(root, true);
756 let event = create_event(&root.join(".gitignore"));
757 assert!(matches!(
758 classify_watch_event(root, true, &filter, &event),
759 Some(WatchProgress::Rebuild)
760 ));
761 }
762
763 #[test]
764 fn reading_the_gitignore_does_not_trigger_a_rebuild() {
765 let dir = fixture();
771 let root = dir.path();
772 let filter = WatchFilter::new(root, true);
773 let read = notify::Event {
774 kind: EventKind::Access(notify::event::AccessKind::Read),
775 paths: vec![root.join(".gitignore")],
776 attrs: notify::event::EventAttributes::new(),
777 };
778 assert!(classify_watch_event(root, true, &filter, &read).is_none());
779 }
780
781 #[test]
782 fn the_filter_still_matches_when_the_root_is_a_symlink() {
783 let dir = fixture();
789 let root = dir.path();
790 let canonical = root.canonicalize().unwrap();
791 let filter = WatchFilter::new(root, true);
792 let event = create_event(&canonical.join("target/debug/liba.rlib"));
793 assert!(classify_watch_event(root, true, &filter, &event).is_none());
794 }
795
796 #[test]
797 fn the_git_directory_is_dropped_without_a_gitignore_rule() {
798 let dir = fixture();
801 let root = dir.path();
802 let filter = WatchFilter::new(root, true);
803 let event = create_event(&root.join(".git/index.lock"));
804 assert!(classify_watch_event(root, true, &filter, &event).is_none());
805 }
806}