Skip to main content

kaish_kernel/
ignore_config.rs

1//! Configurable ignore file policy for file-walking tools.
2//!
3//! Controls which gitignore-format files are loaded and how broadly
4//! ignore rules apply. Per-mode defaults: sandboxed agents get `Enforced`
5//! filtering (context-flood protection), the interactive REPL gets the same
6//! filters at `Advisory` scope (recoverable per call via `--no-ignore` or
7//! per session via `kaish-ignore`), and bare embedded/test kernels get none.
8
9use std::path::{Path, PathBuf};
10
11use crate::walker::{IgnoreFilter, WalkerFs};
12
13/// Controls which tools respect the ignore configuration.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum IgnoreScope {
16    /// Polite tools (glob, tree, grep, ls, expand_glob) respect config.
17    /// `find` remains unrestricted — traditional POSIX behavior.
18    Advisory,
19    /// ALL file-walking tools respect config, including `find`.
20    /// Protects agents from context flooding.
21    Enforced,
22}
23
24/// Centralized ignore file configuration.
25///
26/// Threaded through `KernelConfig` → `ExecContext` → tools.
27/// Runtime-mutable via the `ignore` builtin.
28#[derive(Debug, Clone)]
29pub struct IgnoreConfig {
30    scope: IgnoreScope,
31    ignore_files: Vec<String>,
32    use_defaults: bool,
33    auto_gitignore: bool,
34    /// When true, also load the user's global gitignore file (the path is
35    /// resolved from `core.excludesFile` in `~/.gitconfig`, falling back to
36    /// `~/.config/git/ignore` per git's own resolution). Off by default to
37    /// keep tests hermetic.
38    use_global_gitignore: bool,
39    /// Test-only override for the global gitignore path. When `Some`, this
40    /// path is read instead of resolving via git's standard config lookup.
41    /// Production callers leave this `None`.
42    global_gitignore_path_override: Option<PathBuf>,
43}
44
45impl IgnoreConfig {
46    /// No filtering — embedded/test default.
47    pub fn none() -> Self {
48        Self {
49            scope: IgnoreScope::Advisory,
50            ignore_files: Vec::new(),
51            use_defaults: false,
52            auto_gitignore: false,
53            use_global_gitignore: false,
54            global_gitignore_path_override: None,
55        }
56    }
57
58    /// Interactive-REPL defaults (GH #134): the same filters as `agent()` —
59    /// `.gitignore` loaded, default ignore list on — but at **Advisory**
60    /// scope, so `find` stays POSIX-unrestricted and a human can recover the
61    /// unfiltered view per call (`--no-ignore`) or per session
62    /// (`kaish-ignore clear`).
63    pub fn interactive() -> Self {
64        Self {
65            scope: IgnoreScope::Advisory,
66            ..Self::agent()
67        }
68    }
69
70    /// Sandboxed-agent defaults: enforced scope, .gitignore loaded, defaults on.
71    ///
72    /// NOTE: `interactive()` inherits every field but `scope` from here via
73    /// struct-update syntax — a new field added to this preset carries into
74    /// the REPL preset unless `interactive()` overrides it.
75    pub fn agent() -> Self {
76        Self {
77            scope: IgnoreScope::Enforced,
78            ignore_files: vec![".gitignore".to_string()],
79            use_defaults: true,
80            auto_gitignore: true,
81            use_global_gitignore: false,
82            global_gitignore_path_override: None,
83        }
84    }
85
86    /// Whether any filtering is configured.
87    pub fn is_active(&self) -> bool {
88        self.use_defaults
89            || self.auto_gitignore
90            || !self.ignore_files.is_empty()
91            || self.use_global_gitignore
92    }
93
94    pub fn scope(&self) -> IgnoreScope {
95        self.scope
96    }
97
98    /// Whether the FileWalker should auto-load nested .gitignore files.
99    pub fn auto_gitignore(&self) -> bool {
100        self.auto_gitignore
101    }
102
103    pub fn use_defaults(&self) -> bool {
104        self.use_defaults
105    }
106
107    pub fn files(&self) -> &[String] {
108        &self.ignore_files
109    }
110
111    pub fn set_scope(&mut self, scope: IgnoreScope) {
112        self.scope = scope;
113    }
114
115    pub fn set_defaults(&mut self, on: bool) {
116        self.use_defaults = on;
117    }
118
119    pub fn set_auto_gitignore(&mut self, on: bool) {
120        self.auto_gitignore = on;
121    }
122
123    /// Toggle whether the user's global gitignore is loaded. When enabled,
124    /// the path comes from `core.excludesFile` (falling back to
125    /// `~/.config/git/ignore` per git's lookup), unless an override has
126    /// been set via `set_global_gitignore_path` for tests.
127    pub fn set_use_global_gitignore(&mut self, on: bool) {
128        self.use_global_gitignore = on;
129    }
130
131    pub fn use_global_gitignore(&self) -> bool {
132        self.use_global_gitignore
133    }
134
135    /// Test hook: substitute the global gitignore lookup with a fixed path.
136    /// Production callers leave this unset.
137    pub fn set_global_gitignore_path(&mut self, path: Option<PathBuf>) {
138        self.global_gitignore_path_override = path;
139    }
140
141    pub fn add_file(&mut self, name: &str) {
142        if !self.ignore_files.iter().any(|f| f == name) {
143            self.ignore_files.push(name.to_string());
144        }
145    }
146
147    pub fn remove_file(&mut self, name: &str) {
148        self.ignore_files.retain(|f| f != name);
149    }
150
151    pub fn clear(&mut self) {
152        self.ignore_files.clear();
153        self.use_defaults = false;
154        self.auto_gitignore = false;
155        self.use_global_gitignore = false;
156        self.global_gitignore_path_override = None;
157    }
158
159    /// Build an `IgnoreFilter` from the configured file list and defaults.
160    ///
161    /// Loads each ignore file relative to `root` via the given `WalkerFs`.
162    /// Returns `None` if no filtering is configured.
163    ///
164    /// **Ancestor walk-up.** For each configured ignore filename, this also
165    /// walks up the directory tree from `root` and loads the same filename
166    /// from each ancestor. Rules from ancestor files are *rebased* onto the
167    /// walker's relative-path frame: anchored rules pointing into the
168    /// walker's subtree get their prefix stripped; rules pointing outside
169    /// are dropped; unanchored rules pass through unchanged. Matches git's
170    /// behavior of honoring `.gitignore` files in any ancestor directory.
171    /// Closer ancestors get higher priority (added later).
172    pub async fn build_filter<F: WalkerFs>(
173        &self,
174        root: &Path,
175        fs: &F,
176    ) -> Option<IgnoreFilter> {
177        if !self.is_active() {
178            return None;
179        }
180
181        let mut filter = if self.use_defaults {
182            IgnoreFilter::with_defaults()
183        } else {
184            IgnoreFilter::new()
185        };
186
187        // Global gitignore (one notch above hardcoded defaults). Reads real
188        // disk regardless of which `WalkerFs` we're walking, since the
189        // global file lives outside any project tree. Silently skipped if
190        // the file doesn't exist or cannot be read.
191        if self.use_global_gitignore {
192            let path = self
193                .global_gitignore_path_override
194                .clone()
195                .or_else(ignore::gitignore::gitconfig_excludes_path);
196            if let Some(path) = path
197                && let Ok(content) = std::fs::read_to_string(&path)
198            {
199                for line in content.lines() {
200                    filter.add_rule(line);
201                }
202            }
203        }
204
205        // Walk up from `root` collecting ancestor directories and the
206        // relative path from each ancestor down to `root`. We build the
207        // list closest-first, then reverse so farther ancestors merge
208        // into the filter earlier (= lower priority).
209        let mut ancestors: Vec<(PathBuf, String)> = Vec::new();
210        let mut current = root;
211        while let Some(parent) = current.parent() {
212            // strip_prefix yields the path from parent down to root.
213            if let Ok(rel) = root.strip_prefix(parent) {
214                ancestors.push((
215                    parent.to_path_buf(),
216                    rel.to_string_lossy().into_owned(),
217                ));
218            }
219            if parent == current {
220                break;
221            }
222            current = parent;
223        }
224        ancestors.reverse(); // farthest ancestor first
225
226        for (ancestor_dir, prefix) in &ancestors {
227            for filename in &self.ignore_files {
228                let path = ancestor_dir.join(filename);
229                if !fs.exists(&path).await {
230                    continue;
231                }
232                let Ok(bytes) = fs.read_file(&path).await else {
233                    continue;
234                };
235                let text = String::from_utf8_lossy(&bytes);
236                for line in text.lines() {
237                    if let Some(rebased) = rebase_gitignore_line(line, prefix) {
238                        filter.add_rule(&rebased);
239                    }
240                }
241            }
242        }
243
244        // Root-level ignore files merge last (highest priority).
245        for filename in &self.ignore_files {
246            let path = root.join(filename);
247            if let Ok(file_filter) = IgnoreFilter::from_gitignore(&path, fs).await {
248                filter.merge(&file_filter);
249            }
250            // Silently skip files that don't exist or can't be read
251        }
252
253        Some(filter)
254    }
255}
256
257/// Rewrite a single gitignore line so its rule, when interpreted relative to
258/// a walker root, produces the same set of matches it would have produced if
259/// interpreted relative to the gitignore's own (ancestor) directory.
260///
261/// `prefix` is the path from the gitignore's directory down to the walker
262/// root, e.g. `prefix = "b/c"` when the gitignore lives at `/a/.gitignore`
263/// and the walker is rooted at `/a/b/c`. An empty prefix means the gitignore
264/// is at the walker root itself (caller should usually take the fast path
265/// and use `IgnoreFilter::from_gitignore` directly in that case).
266///
267/// Returns `None` for blank/comment lines, or for anchored rules whose
268/// target path lies outside the walker's subtree (dropped because they
269/// can never match anything we'll walk).
270fn rebase_gitignore_line(line: &str, prefix: &str) -> Option<String> {
271    let trimmed = line.trim();
272    if trimmed.is_empty() || trimmed.starts_with('#') {
273        return None;
274    }
275
276    // Split off the negation marker first so we can re-emit it.
277    let (negated, rest) = if let Some(stripped) = trimmed.strip_prefix('!') {
278        (true, stripped)
279    } else {
280        (false, trimmed)
281    };
282
283    // And the directory-only suffix.
284    let (dir_only, rest) = if let Some(stripped) = rest.strip_suffix('/') {
285        (true, stripped)
286    } else {
287        (false, rest)
288    };
289
290    // A rule is "anchored" in git semantics when it has a leading `/`
291    // OR an internal `/`. Unanchored patterns match anywhere in the tree
292    // and need no rebasing.
293    let leading_slash = rest.starts_with('/');
294    let body = rest.trim_start_matches('/');
295    let is_anchored = leading_slash || body.contains('/');
296
297    let prefix = prefix.trim_matches('/');
298
299    let new_body: String = if !is_anchored {
300        // Unanchored — passes through unchanged.
301        body.to_string()
302    } else if prefix.is_empty() {
303        // Walker is at the gitignore's own directory — rule is already in
304        // the right frame; preserve the leading-slash anchor.
305        format!("/{body}")
306    } else {
307        // The rule's anchored path is interpreted from the gitignore's
308        // directory. Strip our `prefix/` to translate into walker frame;
309        // drop entirely if the rule points outside.
310        if body == prefix {
311            // Rule targets the walker root itself — irrelevant once we're
312            // walking inside it.
313            return None;
314        }
315        let prefix_with_slash = format!("{prefix}/");
316        match body.strip_prefix(&prefix_with_slash) {
317            Some(stripped) => format!("/{stripped}"),
318            None => return None,
319        }
320    };
321
322    let mut out = String::new();
323    if negated {
324        out.push('!');
325    }
326    out.push_str(&new_body);
327    if dir_only {
328        out.push('/');
329    }
330    Some(out)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_none_is_inactive() {
339        let config = IgnoreConfig::none();
340        assert!(!config.is_active());
341        assert_eq!(config.scope(), IgnoreScope::Advisory);
342        assert!(!config.auto_gitignore());
343    }
344
345    #[test]
346    fn test_agent_preset_is_active() {
347        let config = IgnoreConfig::agent();
348        assert!(config.is_active());
349        assert_eq!(config.scope(), IgnoreScope::Enforced);
350        assert!(config.auto_gitignore());
351        assert!(config.use_defaults());
352        assert_eq!(config.files(), &[".gitignore"]);
353    }
354
355    #[test]
356    fn test_add_remove_files() {
357        let mut config = IgnoreConfig::none();
358        assert!(!config.is_active());
359
360        config.add_file(".dockerignore");
361        assert!(config.is_active());
362        assert_eq!(config.files(), &[".dockerignore"]);
363
364        // No duplicates
365        config.add_file(".dockerignore");
366        assert_eq!(config.files().len(), 1);
367
368        config.remove_file(".dockerignore");
369        assert!(config.files().is_empty());
370    }
371
372    #[test]
373    fn test_clear() {
374        let mut config = IgnoreConfig::agent();
375        config.clear();
376        assert!(!config.is_active());
377        assert!(config.files().is_empty());
378        assert!(!config.use_defaults());
379        assert!(!config.auto_gitignore());
380    }
381
382    #[test]
383    fn test_set_scope() {
384        let mut config = IgnoreConfig::none();
385        config.set_scope(IgnoreScope::Enforced);
386        assert_eq!(config.scope(), IgnoreScope::Enforced);
387    }
388
389    #[test]
390    fn test_defaults_toggle() {
391        let mut config = IgnoreConfig::none();
392        config.set_defaults(true);
393        assert!(config.is_active());
394        config.set_defaults(false);
395        assert!(!config.is_active());
396    }
397
398    #[test]
399    fn test_auto_gitignore_alone_is_active() {
400        let mut config = IgnoreConfig::none();
401        assert!(!config.is_active());
402        config.set_auto_gitignore(true);
403        assert!(config.is_active());
404    }
405
406    mod async_tests {
407        use super::*;
408        use crate::walker::{WalkerDirEntry, WalkerError, WalkerFs};
409        use std::collections::HashMap;
410        use std::path::PathBuf;
411
412        struct MemEntry;
413        impl WalkerDirEntry for MemEntry {
414            fn name(&self) -> &str { "" }
415            fn is_dir(&self) -> bool { false }
416            fn is_file(&self) -> bool { true }
417            fn is_symlink(&self) -> bool { false }
418        }
419
420        struct FakeFs(HashMap<PathBuf, Vec<u8>>);
421
422        #[async_trait::async_trait]
423        impl WalkerFs for FakeFs {
424            type DirEntry = MemEntry;
425            async fn list_dir(&self, _: &Path) -> Result<Vec<MemEntry>, WalkerError> {
426                Ok(vec![])
427            }
428            async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
429                self.0.get(path)
430                    .cloned()
431                    .ok_or_else(|| WalkerError::NotFound(path.display().to_string()))
432            }
433            async fn is_dir(&self, _: &Path) -> bool { false }
434            async fn exists(&self, path: &Path) -> bool { self.0.contains_key(path) }
435        }
436
437        #[tokio::test]
438        async fn test_build_filter_none_returns_none() {
439            let config = IgnoreConfig::none();
440            let fs = FakeFs(HashMap::new());
441            assert!(config.build_filter(Path::new("/"), &fs).await.is_none());
442        }
443
444        #[tokio::test]
445        async fn test_build_filter_defaults_returns_some() {
446            let mut config = IgnoreConfig::none();
447            config.set_defaults(true);
448            let fs = FakeFs(HashMap::new());
449
450            let filter = config.build_filter(Path::new("/"), &fs).await;
451            assert!(filter.is_some());
452            let filter = filter.unwrap();
453            // Default filter should ignore target/ and node_modules/
454            assert!(filter.is_name_ignored("target", true));
455            assert!(filter.is_name_ignored("node_modules", true));
456            assert!(!filter.is_name_ignored("src", true));
457        }
458
459        #[tokio::test]
460        async fn test_build_filter_loads_gitignore() {
461            let mut config = IgnoreConfig::none();
462            config.add_file(".gitignore");
463
464            let mut files = HashMap::new();
465            files.insert(PathBuf::from("/project/.gitignore"), b"*.log\nbuild/\n".to_vec());
466            let fs = FakeFs(files);
467
468            let filter = config.build_filter(Path::new("/project"), &fs).await;
469            assert!(filter.is_some());
470            let filter = filter.unwrap();
471            assert!(filter.is_name_ignored("debug.log", false));
472            assert!(filter.is_name_ignored("build", true));
473            assert!(!filter.is_name_ignored("src", true));
474        }
475
476        #[tokio::test]
477        async fn test_build_filter_missing_file_skipped() {
478            let mut config = IgnoreConfig::none();
479            config.add_file(".gitignore");
480            config.add_file(".nonexistent");
481
482            let mut files = HashMap::new();
483            files.insert(PathBuf::from("/root/.gitignore"), b"*.tmp\n".to_vec());
484            let fs = FakeFs(files);
485
486            // Should not error — missing .nonexistent is silently skipped
487            let filter = config.build_filter(Path::new("/root"), &fs).await;
488            assert!(filter.is_some());
489            let filter = filter.unwrap();
490            assert!(filter.is_name_ignored("test.tmp", false));
491        }
492
493        #[tokio::test]
494        async fn test_build_filter_defaults_plus_gitignore_merged() {
495            let config = IgnoreConfig::agent();
496
497            let mut files = HashMap::new();
498            files.insert(PathBuf::from("/project/.gitignore"), b"*.secret\n".to_vec());
499            let fs = FakeFs(files);
500
501            let filter = config.build_filter(Path::new("/project"), &fs).await;
502            assert!(filter.is_some());
503            let filter = filter.unwrap();
504            // Defaults
505            assert!(filter.is_name_ignored("target", true));
506            assert!(filter.is_name_ignored("node_modules", true));
507            // From .gitignore
508            assert!(filter.is_name_ignored("passwords.secret", false));
509            // Normal files pass through
510            assert!(!filter.is_name_ignored("main.rs", false));
511        }
512
513        /// Parent-directory `.gitignore` walk-up. When the walker is started
514        /// at `/a/b`, a `.gitignore` at `/a/` should still apply (per git
515        /// semantics — git looks at every ancestor up to the repo root).
516        ///
517        /// Unanchored rule (`*.log`) matches anywhere — must hide files in
518        /// the walker's tree. Anchored rule with explicit subpath
519        /// (`b/secret.txt`) must match the file at the right location once
520        /// rebased to the walker frame.
521        #[tokio::test]
522        async fn test_build_filter_parent_gitignore_walk_up() {
523            let mut config = IgnoreConfig::none();
524            config.add_file(".gitignore");
525
526            let mut files = HashMap::new();
527            files.insert(
528                PathBuf::from("/a/.gitignore"),
529                b"*.log\nb/secret.txt\n".to_vec(),
530            );
531            let fs = FakeFs(files);
532
533            // Walker rooted at /a/b — its files have paths relative to /a/b.
534            let filter = config.build_filter(Path::new("/a/b"), &fs).await;
535            assert!(filter.is_some(), "filter should be loaded from ancestor");
536            let filter = filter.unwrap();
537
538            // Unanchored *.log rule from /a/.gitignore must reach into /a/b.
539            assert!(
540                filter.is_ignored(Path::new("debug.log"), false),
541                "ancestor's *.log must apply in subtree",
542            );
543            assert!(
544                filter.is_ignored(Path::new("nested/dir/app.log"), false),
545                "ancestor's *.log must reach nested files in subtree",
546            );
547
548            // The anchored "b/secret.txt" from /a/.gitignore points at /a/b/secret.txt,
549            // which in our walker frame is just "secret.txt".
550            assert!(
551                filter.is_ignored(Path::new("secret.txt"), false),
552                "anchored ancestor rule must rebase to walker frame",
553            );
554
555            // A regular file still passes through.
556            assert!(!filter.is_ignored(Path::new("main.rs"), false));
557        }
558
559        /// `.ignore` / `.rgignore` files are loaded with higher precedence
560        /// than `.gitignore`. A negation in `.ignore` should override a
561        /// matching ignore from `.gitignore` — that's the rg behavior we
562        /// want.
563        #[tokio::test]
564        async fn test_build_filter_dot_ignore_overrides_gitignore() {
565            let mut config = IgnoreConfig::none();
566            // Order matters: later-added = higher precedence.
567            config.add_file(".gitignore");
568            config.add_file(".ignore");
569            config.add_file(".rgignore");
570
571            let mut files = HashMap::new();
572            files.insert(PathBuf::from("/proj/.gitignore"), b"*.log\n".to_vec());
573            // .ignore un-ignores keep.log
574            files.insert(PathBuf::from("/proj/.ignore"), b"!keep.log\n".to_vec());
575            let fs = FakeFs(files);
576
577            let filter = config.build_filter(Path::new("/proj"), &fs).await;
578            assert!(filter.is_some());
579            let filter = filter.unwrap();
580
581            assert!(
582                filter.is_ignored(Path::new("debug.log"), false),
583                ".gitignore *.log still applies",
584            );
585            assert!(
586                !filter.is_ignored(Path::new("keep.log"), false),
587                ".ignore negation must override .gitignore",
588            );
589        }
590
591        /// Global gitignore file is honored when the flag is set. The walker
592        /// FS doesn't carry the global file (it lives outside any project);
593        /// the read goes through real disk via tokio. We use the
594        /// `set_global_gitignore_path` test hook so we don't depend on
595        /// `$HOME` / `$XDG_CONFIG_HOME` and stay safe under parallel tests.
596        #[tokio::test]
597        async fn test_build_filter_global_gitignore_honored() {
598            let tmp = tempfile::tempdir().expect("tempdir");
599            let global_path = tmp.path().join("git_ignore");
600            // Write the fixture with std::fs (not tokio::fs) so this test
601            // compiles in the minimal `--no-default-features` build, which
602            // doesn't enable tokio's `fs` feature. Production reads this file
603            // with `std::fs::read_to_string` too (see build_filter), so this
604            // stays faithful to the real path.
605            std::fs::write(&global_path, b"*.global_secret\n").expect("write global gitignore");
606
607            let mut config = IgnoreConfig::none();
608            config.set_use_global_gitignore(true);
609            config.set_global_gitignore_path(Some(global_path));
610
611            // build_filter ignores the WalkerFs for the global file (it
612            // reads real disk), so an empty FakeFs is fine.
613            let fs = FakeFs(HashMap::new());
614
615            let filter = config.build_filter(Path::new("/proj"), &fs).await;
616            assert!(filter.is_some(), "global gitignore must activate filtering");
617            let filter = filter.unwrap();
618
619            assert!(
620                filter.is_ignored(Path::new("creds.global_secret"), false),
621                "global gitignore rule must apply",
622            );
623            assert!(!filter.is_ignored(Path::new("main.rs"), false));
624        }
625
626        /// Global gitignore enabled but file missing: silent skip, no error,
627        /// no rules added (filter still active because the flag is set).
628        #[tokio::test]
629        async fn test_build_filter_global_gitignore_missing_file_ok() {
630            let tmp = tempfile::tempdir().expect("tempdir");
631            // Path that doesn't exist.
632            let global_path = tmp.path().join("does_not_exist");
633
634            let mut config = IgnoreConfig::none();
635            config.set_use_global_gitignore(true);
636            config.set_global_gitignore_path(Some(global_path));
637
638            let fs = FakeFs(HashMap::new());
639            let filter = config.build_filter(Path::new("/proj"), &fs).await;
640            // Active flag still produces Some(filter), even if no rules loaded.
641            assert!(filter.is_some());
642        }
643
644        /// `.rgignore` is highest-precedence and can override `.ignore`.
645        #[tokio::test]
646        async fn test_build_filter_rgignore_highest_precedence() {
647            let mut config = IgnoreConfig::none();
648            config.add_file(".gitignore");
649            config.add_file(".ignore");
650            config.add_file(".rgignore");
651
652            let mut files = HashMap::new();
653            files.insert(PathBuf::from("/proj/.ignore"), b"!keep.log\n".to_vec());
654            // .rgignore re-ignores keep.log; should win.
655            files.insert(PathBuf::from("/proj/.rgignore"), b"keep.log\n".to_vec());
656            let fs = FakeFs(files);
657
658            let filter = config.build_filter(Path::new("/proj"), &fs).await;
659            let filter = filter.unwrap();
660
661            assert!(
662                filter.is_ignored(Path::new("keep.log"), false),
663                ".rgignore must override .ignore",
664            );
665        }
666
667        /// Ancestor anchored rule that points OUTSIDE the walker root is
668        /// dropped: `/a/.gitignore` saying `c/foo.txt` (= /a/c/foo.txt)
669        /// must not match `/a/b/c/foo.txt` in our subtree.
670        #[tokio::test]
671        async fn test_build_filter_parent_anchored_rule_outside_subtree_dropped() {
672            let mut config = IgnoreConfig::none();
673            config.add_file(".gitignore");
674
675            let mut files = HashMap::new();
676            files.insert(PathBuf::from("/a/.gitignore"), b"c/foo.txt\n".to_vec());
677            let fs = FakeFs(files);
678
679            // Walker rooted at /a/b — the ancestor rule's anchored target
680            // /a/c/foo.txt is NOT under our subtree, so it should be dropped.
681            let filter = config.build_filter(Path::new("/a/b"), &fs).await;
682            assert!(filter.is_some());
683            let filter = filter.unwrap();
684
685            // /a/b/c/foo.txt → relative "c/foo.txt" must NOT match the
686            // rebased-and-dropped ancestor rule.
687            assert!(
688                !filter.is_ignored(Path::new("c/foo.txt"), false),
689                "anchored ancestor rule outside subtree must be dropped",
690            );
691        }
692    }
693}