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