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        let stripped = body.strip_prefix(&prefix_with_slash)?;
317        format!("/{stripped}")
318    };
319
320    let mut out = String::new();
321    if negated {
322        out.push('!');
323    }
324    out.push_str(&new_body);
325    if dir_only {
326        out.push('/');
327    }
328    Some(out)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_none_is_inactive() {
337        let config = IgnoreConfig::none();
338        assert!(!config.is_active());
339        assert_eq!(config.scope(), IgnoreScope::Advisory);
340        assert!(!config.auto_gitignore());
341    }
342
343    #[test]
344    fn test_agent_preset_is_active() {
345        let config = IgnoreConfig::agent();
346        assert!(config.is_active());
347        assert_eq!(config.scope(), IgnoreScope::Enforced);
348        assert!(config.auto_gitignore());
349        assert!(config.use_defaults());
350        assert_eq!(config.files(), &[".gitignore"]);
351    }
352
353    #[test]
354    fn test_add_remove_files() {
355        let mut config = IgnoreConfig::none();
356        assert!(!config.is_active());
357
358        config.add_file(".dockerignore");
359        assert!(config.is_active());
360        assert_eq!(config.files(), &[".dockerignore"]);
361
362        // No duplicates
363        config.add_file(".dockerignore");
364        assert_eq!(config.files().len(), 1);
365
366        config.remove_file(".dockerignore");
367        assert!(config.files().is_empty());
368    }
369
370    #[test]
371    fn test_clear() {
372        let mut config = IgnoreConfig::agent();
373        config.clear();
374        assert!(!config.is_active());
375        assert!(config.files().is_empty());
376        assert!(!config.use_defaults());
377        assert!(!config.auto_gitignore());
378    }
379
380    #[test]
381    fn test_set_scope() {
382        let mut config = IgnoreConfig::none();
383        config.set_scope(IgnoreScope::Enforced);
384        assert_eq!(config.scope(), IgnoreScope::Enforced);
385    }
386
387    #[test]
388    fn test_defaults_toggle() {
389        let mut config = IgnoreConfig::none();
390        config.set_defaults(true);
391        assert!(config.is_active());
392        config.set_defaults(false);
393        assert!(!config.is_active());
394    }
395
396    #[test]
397    fn test_auto_gitignore_alone_is_active() {
398        let mut config = IgnoreConfig::none();
399        assert!(!config.is_active());
400        config.set_auto_gitignore(true);
401        assert!(config.is_active());
402    }
403
404    mod async_tests {
405        use super::*;
406        use crate::walker::{WalkerDirEntry, WalkerError, WalkerFs};
407        use std::collections::HashMap;
408        use std::path::PathBuf;
409
410        struct MemEntry;
411        impl WalkerDirEntry for MemEntry {
412            fn name(&self) -> &str { "" }
413            fn is_dir(&self) -> bool { false }
414            fn is_file(&self) -> bool { true }
415            fn is_symlink(&self) -> bool { false }
416        }
417
418        struct FakeFs(HashMap<PathBuf, Vec<u8>>);
419
420        #[async_trait::async_trait]
421        impl WalkerFs for FakeFs {
422            type DirEntry = MemEntry;
423            async fn list_dir(&self, _: &Path) -> Result<Vec<MemEntry>, WalkerError> {
424                Ok(vec![])
425            }
426            async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
427                self.0.get(path)
428                    .cloned()
429                    .ok_or_else(|| WalkerError::NotFound(path.display().to_string()))
430            }
431            async fn is_dir(&self, _: &Path) -> bool { false }
432            async fn exists(&self, path: &Path) -> bool { self.0.contains_key(path) }
433        }
434
435        #[tokio::test]
436        async fn test_build_filter_none_returns_none() {
437            let config = IgnoreConfig::none();
438            let fs = FakeFs(HashMap::new());
439            assert!(config.build_filter(Path::new("/"), &fs).await.is_none());
440        }
441
442        #[tokio::test]
443        async fn test_build_filter_defaults_returns_some() {
444            let mut config = IgnoreConfig::none();
445            config.set_defaults(true);
446            let fs = FakeFs(HashMap::new());
447
448            let filter = config.build_filter(Path::new("/"), &fs).await;
449            assert!(filter.is_some());
450            let filter = filter.unwrap();
451            // Default filter should ignore target/ and node_modules/
452            assert!(filter.is_name_ignored("target", true));
453            assert!(filter.is_name_ignored("node_modules", true));
454            assert!(!filter.is_name_ignored("src", true));
455        }
456
457        #[tokio::test]
458        async fn test_build_filter_loads_gitignore() {
459            let mut config = IgnoreConfig::none();
460            config.add_file(".gitignore");
461
462            let mut files = HashMap::new();
463            files.insert(PathBuf::from("/project/.gitignore"), b"*.log\nbuild/\n".to_vec());
464            let fs = FakeFs(files);
465
466            let filter = config.build_filter(Path::new("/project"), &fs).await;
467            assert!(filter.is_some());
468            let filter = filter.unwrap();
469            assert!(filter.is_name_ignored("debug.log", false));
470            assert!(filter.is_name_ignored("build", true));
471            assert!(!filter.is_name_ignored("src", true));
472        }
473
474        #[tokio::test]
475        async fn test_build_filter_missing_file_skipped() {
476            let mut config = IgnoreConfig::none();
477            config.add_file(".gitignore");
478            config.add_file(".nonexistent");
479
480            let mut files = HashMap::new();
481            files.insert(PathBuf::from("/root/.gitignore"), b"*.tmp\n".to_vec());
482            let fs = FakeFs(files);
483
484            // Should not error — missing .nonexistent is silently skipped
485            let filter = config.build_filter(Path::new("/root"), &fs).await;
486            assert!(filter.is_some());
487            let filter = filter.unwrap();
488            assert!(filter.is_name_ignored("test.tmp", false));
489        }
490
491        #[tokio::test]
492        async fn test_build_filter_defaults_plus_gitignore_merged() {
493            let config = IgnoreConfig::agent();
494
495            let mut files = HashMap::new();
496            files.insert(PathBuf::from("/project/.gitignore"), b"*.secret\n".to_vec());
497            let fs = FakeFs(files);
498
499            let filter = config.build_filter(Path::new("/project"), &fs).await;
500            assert!(filter.is_some());
501            let filter = filter.unwrap();
502            // Defaults
503            assert!(filter.is_name_ignored("target", true));
504            assert!(filter.is_name_ignored("node_modules", true));
505            // From .gitignore
506            assert!(filter.is_name_ignored("passwords.secret", false));
507            // Normal files pass through
508            assert!(!filter.is_name_ignored("main.rs", false));
509        }
510
511        /// Parent-directory `.gitignore` walk-up. When the walker is started
512        /// at `/a/b`, a `.gitignore` at `/a/` should still apply (per git
513        /// semantics — git looks at every ancestor up to the repo root).
514        ///
515        /// Unanchored rule (`*.log`) matches anywhere — must hide files in
516        /// the walker's tree. Anchored rule with explicit subpath
517        /// (`b/secret.txt`) must match the file at the right location once
518        /// rebased to the walker frame.
519        #[tokio::test]
520        async fn test_build_filter_parent_gitignore_walk_up() {
521            let mut config = IgnoreConfig::none();
522            config.add_file(".gitignore");
523
524            let mut files = HashMap::new();
525            files.insert(
526                PathBuf::from("/a/.gitignore"),
527                b"*.log\nb/secret.txt\n".to_vec(),
528            );
529            let fs = FakeFs(files);
530
531            // Walker rooted at /a/b — its files have paths relative to /a/b.
532            let filter = config.build_filter(Path::new("/a/b"), &fs).await;
533            assert!(filter.is_some(), "filter should be loaded from ancestor");
534            let filter = filter.unwrap();
535
536            // Unanchored *.log rule from /a/.gitignore must reach into /a/b.
537            assert!(
538                filter.is_ignored(Path::new("debug.log"), false),
539                "ancestor's *.log must apply in subtree",
540            );
541            assert!(
542                filter.is_ignored(Path::new("nested/dir/app.log"), false),
543                "ancestor's *.log must reach nested files in subtree",
544            );
545
546            // The anchored "b/secret.txt" from /a/.gitignore points at /a/b/secret.txt,
547            // which in our walker frame is just "secret.txt".
548            assert!(
549                filter.is_ignored(Path::new("secret.txt"), false),
550                "anchored ancestor rule must rebase to walker frame",
551            );
552
553            // A regular file still passes through.
554            assert!(!filter.is_ignored(Path::new("main.rs"), false));
555        }
556
557        /// `.ignore` / `.rgignore` files are loaded with higher precedence
558        /// than `.gitignore`. A negation in `.ignore` should override a
559        /// matching ignore from `.gitignore` — that's the rg behavior we
560        /// want.
561        #[tokio::test]
562        async fn test_build_filter_dot_ignore_overrides_gitignore() {
563            let mut config = IgnoreConfig::none();
564            // Order matters: later-added = higher precedence.
565            config.add_file(".gitignore");
566            config.add_file(".ignore");
567            config.add_file(".rgignore");
568
569            let mut files = HashMap::new();
570            files.insert(PathBuf::from("/proj/.gitignore"), b"*.log\n".to_vec());
571            // .ignore un-ignores keep.log
572            files.insert(PathBuf::from("/proj/.ignore"), b"!keep.log\n".to_vec());
573            let fs = FakeFs(files);
574
575            let filter = config.build_filter(Path::new("/proj"), &fs).await;
576            assert!(filter.is_some());
577            let filter = filter.unwrap();
578
579            assert!(
580                filter.is_ignored(Path::new("debug.log"), false),
581                ".gitignore *.log still applies",
582            );
583            assert!(
584                !filter.is_ignored(Path::new("keep.log"), false),
585                ".ignore negation must override .gitignore",
586            );
587        }
588
589        /// Global gitignore file is honored when the flag is set. The walker
590        /// FS doesn't carry the global file (it lives outside any project);
591        /// the read goes through real disk via tokio. We use the
592        /// `set_global_gitignore_path` test hook so we don't depend on
593        /// `$HOME` / `$XDG_CONFIG_HOME` and stay safe under parallel tests.
594        #[tokio::test]
595        async fn test_build_filter_global_gitignore_honored() {
596            let tmp = tempfile::tempdir().expect("tempdir");
597            let global_path = tmp.path().join("git_ignore");
598            // Write the fixture with std::fs (not tokio::fs) so this test
599            // compiles in the minimal `--no-default-features` build, which
600            // doesn't enable tokio's `fs` feature. Production reads this file
601            // with `std::fs::read_to_string` too (see build_filter), so this
602            // stays faithful to the real path.
603            std::fs::write(&global_path, b"*.global_secret\n").expect("write global gitignore");
604
605            let mut config = IgnoreConfig::none();
606            config.set_use_global_gitignore(true);
607            config.set_global_gitignore_path(Some(global_path));
608
609            // build_filter ignores the WalkerFs for the global file (it
610            // reads real disk), so an empty FakeFs is fine.
611            let fs = FakeFs(HashMap::new());
612
613            let filter = config.build_filter(Path::new("/proj"), &fs).await;
614            assert!(filter.is_some(), "global gitignore must activate filtering");
615            let filter = filter.unwrap();
616
617            assert!(
618                filter.is_ignored(Path::new("creds.global_secret"), false),
619                "global gitignore rule must apply",
620            );
621            assert!(!filter.is_ignored(Path::new("main.rs"), false));
622        }
623
624        /// Global gitignore enabled but file missing: silent skip, no error,
625        /// no rules added (filter still active because the flag is set).
626        #[tokio::test]
627        async fn test_build_filter_global_gitignore_missing_file_ok() {
628            let tmp = tempfile::tempdir().expect("tempdir");
629            // Path that doesn't exist.
630            let global_path = tmp.path().join("does_not_exist");
631
632            let mut config = IgnoreConfig::none();
633            config.set_use_global_gitignore(true);
634            config.set_global_gitignore_path(Some(global_path));
635
636            let fs = FakeFs(HashMap::new());
637            let filter = config.build_filter(Path::new("/proj"), &fs).await;
638            // Active flag still produces Some(filter), even if no rules loaded.
639            assert!(filter.is_some());
640        }
641
642        /// `.rgignore` is highest-precedence and can override `.ignore`.
643        #[tokio::test]
644        async fn test_build_filter_rgignore_highest_precedence() {
645            let mut config = IgnoreConfig::none();
646            config.add_file(".gitignore");
647            config.add_file(".ignore");
648            config.add_file(".rgignore");
649
650            let mut files = HashMap::new();
651            files.insert(PathBuf::from("/proj/.ignore"), b"!keep.log\n".to_vec());
652            // .rgignore re-ignores keep.log; should win.
653            files.insert(PathBuf::from("/proj/.rgignore"), b"keep.log\n".to_vec());
654            let fs = FakeFs(files);
655
656            let filter = config.build_filter(Path::new("/proj"), &fs).await;
657            let filter = filter.unwrap();
658
659            assert!(
660                filter.is_ignored(Path::new("keep.log"), false),
661                ".rgignore must override .ignore",
662            );
663        }
664
665        /// Ancestor anchored rule that points OUTSIDE the walker root is
666        /// dropped: `/a/.gitignore` saying `c/foo.txt` (= /a/c/foo.txt)
667        /// must not match `/a/b/c/foo.txt` in our subtree.
668        #[tokio::test]
669        async fn test_build_filter_parent_anchored_rule_outside_subtree_dropped() {
670            let mut config = IgnoreConfig::none();
671            config.add_file(".gitignore");
672
673            let mut files = HashMap::new();
674            files.insert(PathBuf::from("/a/.gitignore"), b"c/foo.txt\n".to_vec());
675            let fs = FakeFs(files);
676
677            // Walker rooted at /a/b — the ancestor rule's anchored target
678            // /a/c/foo.txt is NOT under our subtree, so it should be dropped.
679            let filter = config.build_filter(Path::new("/a/b"), &fs).await;
680            assert!(filter.is_some());
681            let filter = filter.unwrap();
682
683            // /a/b/c/foo.txt → relative "c/foo.txt" must NOT match the
684            // rebased-and-dropped ancestor rule.
685            assert!(
686                !filter.is_ignored(Path::new("c/foo.txt"), false),
687                "anchored ancestor rule outside subtree must be dropped",
688            );
689        }
690    }
691}