@ 261027.5

261027.1 · 9d07a922605313dc47b78b802c7b0616fd76d409

ignore: reuse incremental matchers for directories without rules

When a directory adds no ignore rules or repository boundary, we now reuse the parent matcher. Incremental matching already tracks visited directories separately, so adding an ignore file later preserves the existing cached snapshot.

On Linux x86-64 against 3fce3b5, warm incremental replay over Ruff and uv path sets improves 4.6–6.6% and 3.2–6.2%, respectively, across default and fat-LTO builds. Dense-rule controls stay within 0.6%; the synthetic warm control is flat with fat LTO.

PR #3560

reading declarations

crates/ignore/src/dir.rs

@@ -283,6 +283,31 @@ impl Ignore {         )     } +    /// Like `add_child`, but reuse this matcher when the child adds no rules+    /// or repository boundary. Incremental matching tracks directory paths+    /// separately, so it doesn't need empty nodes in the matcher chain.+    pub(crate) fn add_child_with_possible_reuse(+        &self,+        dir: &Path,+    ) -> (Ignore, Option<Error>) {+        let (ig, err) = self.add_child_path(dir);+        if !ig.has_git+            && ig.custom_ignore_matcher.is_empty()+            && ig.ignore_matcher.is_empty()+            && ig.git_ignore_matcher.is_empty()+            && ig.git_exclude_matcher.is_empty()+        {+            return (self.clone(), err);+        }+        (+            Ignore {+                inner: Arc::new(ig),+                absolute_base: self.absolute_base.clone(),+            },+            err,+        )+    }+     /// Like add_child, but uses successful read_dir entries to reduce     /// probing when discovering ignore files.     pub(crate) fn add_child_with_entries<P: AsRef<Path>>(

crates/ignore/src/incremental.rs

@@ -423,7 +423,7 @@ impl IncrementalIgnore {                     .insert(dir.as_os_str().to_owned(), CachedDir::Ignored);                 return Some(IncrementalMatch::ignore());             }-            let (child, err) = ignore.add_child(&path);+            let (child, err) = ignore.add_child_with_possible_reuse(&path);             errs.maybe_push(err);             self.dirs.insert(                 dir.as_os_str().to_owned(),@@ -1340,4 +1340,24 @@ mod tests {         assert!(dir.is_ignore());         assert!(!dir.is_within_depth());     }+    #[test]+    fn empty_directories_preserve_repository_boundaries() {+        let td = tmpdir();+        mkdirp(td.path().join(".git"));+        mkdirp(td.path().join("a/b/.git"));+        mkdirp(td.path().join("a/b/c"));+        wfile(td.path().join(".gitignore"), "git-blocked\n");+        wfile(td.path().join(".ignore"), "tool-blocked\n!allowed\n");++        let mut m = one_matcher(&builder(td.path()));+        assert!(matchedf(&mut m, "a/git-blocked").is_ignore());+        assert!(matchedf(&mut m, "a/b/c/git-blocked").is_none());+        assert!(matchedf(&mut m, "a/b/c/tool-blocked").is_ignore());+        assert!(matchedf(&mut m, "a/b/c/allowed").is_whitelist());++        wfile(td.path().join("a/b/c/.ignore"), "git-blocked\n");+        assert!(matchedf(&mut m, "a/b/c/git-blocked").is_none());+        let mut rebuilt = one_matcher(&builder(td.path()));+        assert!(matchedf(&mut rebuilt, "a/b/c/git-blocked").is_ignore());+    } }