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