Skip to main content

forge_agent/env/
file_index.rs

1//! File-index walker + filesystem watcher for the user's project
2//! tree. Speaks the language of paths, `ignore::WalkBuilder` walks,
3//! and `notify::Watcher` events; produces typed snapshots and change
4//! batches without knowing anything about session routing.
5//!
6//! View consumers wrap these progress streams with their per-session
7//! `SessionSlot` + generation routing; the env layer is route-blind.
8//!
9//! Pattern mirrors `env::git_diff` (env owns subprocess + parsing,
10//! workspace mediates, a view consumes via channel).
11
12use 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);
23/// Upper bound on events coalesced into one pass, so a sustained
24/// writer cannot starve the cancel check at the top of the loop.
25const 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
42/// Streaming-scan progress. Producers emit `Batch` repeatedly then
43/// `Finished` once when the walk completes.
44pub enum ScanProgress {
45    Batch(Vec<FileCandidate>),
46    Finished,
47}
48
49/// Watcher progress. `Changes` carries a delta batch; `Rebuild` is a
50/// rescan signal (.gitignore changed or notify event we can't classify).
51pub enum WatchProgress {
52    Changes(Vec<FileIndexChange>),
53    Rebuild,
54}
55
56/// Cancel guard for a backgrounded scan or watch. Drop to flip the
57/// shared atomic that producers poll between batches / events.
58pub struct CancelToken(Arc<AtomicBool>);
59
60impl Drop for CancelToken {
61    fn drop(&mut self) {
62        self.0.store(true, AtomicOrdering::Relaxed);
63    }
64}
65
66/// Spawn a streaming filesystem scan rooted at `root`. Returns a
67/// receiver of [`ScanProgress`] batches (`Batch` until the walker
68/// finishes, then one terminal `Finished`) and a cancel handle.
69/// Dropping the cancel handle aborts the walker.
70pub 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
100/// Spawn a recursive filesystem watch rooted at `root`. Returns a
101/// receiver of [`WatchProgress`] events and a cancel handle.
102/// Dropping the cancel handle stops the watcher.
103pub 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            // Drain whatever else is already queued. A build emits
136            // events in bursts and each one used to be an independent
137            // rescan; taking the burst in one pass lets the dedupe in
138            // `collect_parent_rescan_changes` collapse them.
139            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                // The ignore rules themselves may be what changed, so
169                // the matcher has to come back with them.
170                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
184/// Spawn a recursive watch rooted at `root` that reports ONE message per
185/// burst of changes the tree saw.
186///
187/// The same notify wiring, burst drain and ignore filter the index watch
188/// above uses, without its per-path classification: a caller that only needs
189/// to know the tree moved should not pay for the walk a created directory
190/// costs the index. Paths under `.git` are ignored by the filter, so a commit
191/// or a stage fires nothing at all - those ride the caller's own cadence.
192pub 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            // Drained the way the index watch drains one, so a build is one
226            // report rather than one per event.
227            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                            // The ignore rules themselves moved, so the
243                            // matcher has to come back with them.
244                            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                    // An event notify could not deliver is still a burst the
253                    // caller has to hear about, or the tree could sit
254                    // unread behind a broken watch.
255                    Err(_) => moved = true,
256                }
257            }
258            if moved && tx.send(()).is_err() {
259                break;
260            }
261        }
262    });
263    (rx, CancelToken(cancel))
264}
265
266/// Synchronous walk that returns every candidate under `walk_root`.
267/// Used by inline subtree refreshes triggered by watcher events.
268pub 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
281/// Whether a root-relative path is under the root's own `.git` - the walk
282/// drops it the way the watch does, with one ruling so the two cannot drift.
283fn is_under_git(rel: &Path) -> bool {
284    rel.components().next().is_some_and(|first| first.as_os_str() == ".git")
285}
286
287/// Walk `walk_root` (rooted relative to `root`), invoking `emit` for
288/// each `FileCandidate`. `emit` returning `false` cancels the walk.
289/// Returns `true` when the walk completed naturally, `false` when
290/// cancelled (either by `emit`'s return or the optional cancel flag).
291fn 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
365/// Decides whether a watcher event path is worth acting on at all.
366///
367/// The watcher registers the whole root recursively and `notify` has
368/// no concept of ignore rules, so events under `target/` arrive like
369/// any other. Handing one to the walk does NOT filter it either: the
370/// `ignore` crate applies its rules to what it finds under a walk root
371/// and never to the root itself, so pointing a walk at a changed
372/// directory inside an ignored tree walks the whole tree. Matching
373/// here, against a matcher built once per root, is what keeps a cargo
374/// build in a watched project from costing a full rescan per event.
375pub(crate) struct WatchFilter {
376    matcher: Option<ignore::gitignore::Gitignore>,
377    /// The matcher is rooted here and every path is rebased onto it
378    /// before matching. Two reasons, and the second one bites hard:
379    /// the watcher reports canonical paths, so on macOS it says
380    /// `/private/var/...` where the configured root says `/var/...`;
381    /// and `matched_path_or_any_parents` PANICS on a path that is not
382    /// under its root, which would take the watcher thread down on
383    /// the first event rather than merely failing to filter.
384    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        // `matched_path_or_any_parents` is the variant that works
411        // without having walked down to the path; the plain `matched`
412        // would miss `target/debug/x` against a `/target` rule. It is
413        // also the one that panics off-root, hence the rebase.
414        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: &notify::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        // macOS FSEvents emits two separate RenameMode::Any events
521        // (one per path) instead of a single paired event. If the
522        // path no longer exists it is the "from" side of the rename
523        // and should be treated as a remove.
524        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    /// Every path under `.git` is filtered out, which is what lets a caller
598    /// ride its own cadence for a commit or a stage rather than watching for
599    /// one: git's own bookkeeping moves on almost every git command, and none
600    /// of it changes what the tree's row says.
601    #[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    /// The walk drops `.git` the way the watch already does: git's own
620    /// bookkeeping moves on almost every git command, and an entry indexed
621    /// there is one the watch can never mark dirty.
622    #[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    /// The ruling is on the index root's own `.git` and nothing else: a
645    /// nested checkout is the user's content, so the subtree walk a watcher
646    /// event runs keeps its rows.
647    #[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    /// The wait ends when the watch reports, so this bound is reached only by
666    /// a stuck watch or a delivery slower still. The test holds one such
667    /// wait, so it sits 20 s under nextest's 120 s kill and the failure names
668    /// the property instead of dying as a bare timeout.
669    const WEDGE: Duration = Duration::from_secs(100);
670
671    /// The change watch reports a write to the tree.
672    ///
673    /// **The wait is the stream, not a clock.** The watch arms on its own
674    /// thread and a write that beats it is swallowed, so a writer keeps
675    /// writing until the report lands; what ends the test is the watch
676    /// reporting, and [`WEDGE`] is only the stuck-watch detector.
677    #[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        // #523: the watcher registers the whole root recursively and
714        // has no ignore filter, so a cargo build under a gitignored
715        // `target/` used to reach `collect_candidates`. That builds a
716        // fresh WalkBuilder aimed at the changed path, and the ignore
717        // crate never filters a walk's own ROOT - so the whole tree
718        // got walked and sorted for an event we do not care about.
719        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        // `respect_gitignore = false` must keep seeing everything;
741        // the filter is not a second, independent policy.
742        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        // The rebuild signal must survive the filter, otherwise the
752        // matcher can never be refreshed.
753        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        // #523: building the matcher reads `.gitignore` from inside
766        // the watched tree, and on inotify a read is a watchable
767        // event. When any event kind on that file forced a rebuild,
768        // and every rebuild rebuilt the matcher, the rebuild fed
769        // itself. Only a content change may trigger one.
770        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        // macOS hands out `/var/...` while the watcher reports
784        // `/private/var/...`. Without the canonical fallback the
785        // strip_prefix fails for every event and the whole filter
786        // silently passes everything through - which is exactly how
787        // this fix could look like it worked while doing nothing.
788        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        // `.git/` is never listed in .gitignore but churns constantly
799        // during any git operation.
800        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}