exclude whole directories when a @source not glob matches them

`@source not "./src/ba*"` is normalized to base `src` + pattern `/ba*`
(the wildcard can't be hoisted into the base), but the pattern-walker's
not-directive check only glob-matched the file path itself: `/ba*` never
matches `/bar/index.html` since `*` doesn't cross `/`, and the directory
pruning only recognized the normalized `/**/*` shape. So wildcard directory
exclusions were silently ignored by pattern sources.

Follow gitignore semantics instead: a pattern that matches a directory
excludes the whole subtree. `NotRule::matches` now tests the path itself
and every ancestor directory up to the directive's base, and replaces both
the file-level check and the directory pruning check (the `/**/*` sentinel
special case falls out naturally). The auto/external walker already behaved
correctly because on-disk gitignore semantics apply there natively.
This commit is contained in:
Robin Malfait 2026-08-12 15:37:58 +02:00
parent fb43708997
commit d78a138ca3
No known key found for this signature in database

View file

@ -920,19 +920,24 @@ struct NotRule {
}
impl NotRule {
/// Whether this directive excludes the given file.
fn matches_file(&self, path: &Path) -> bool {
/// Whether this directive excludes the given path: a file, or a directory and thereby
/// everything inside of it.
///
/// Like a gitignore rule, the pattern excludes a whole subtree when it matches a
/// directory, so besides the path itself every ancestor directory (up to the directive's
/// base) is tested as well. E.g. `@source not "./src/ba*"` excludes `src/bar/index.html`
/// because `/ba*` matches the `src/bar` directory. Note that directory-shaped directives
/// (`@source not "./some/dir"`) are normalized to a `/**/*` pattern with the directory as
/// its base, which matches everything inside the directory directly.
fn matches(&self, path: &Path) -> bool {
let Ok(remainder) = path.strip_prefix(&self.base) else {
return false;
};
glob_match(&self.pattern, rooted_posix(remainder).as_bytes())
}
/// Whether this directive excludes the entire directory (and everything inside).
fn covers_dir(&self, path: &Path) -> bool {
// Directory-shaped `@source not "./some/dir"` directives are normalized to a `/**/*`
// pattern with the directory as its base.
self.pattern == "/**/*" && path.starts_with(&self.base)
remainder.ancestors().any(|prefix| {
!prefix.as_os_str().is_empty()
&& glob_match(&self.pattern, rooted_posix(prefix).as_bytes())
})
}
}
@ -1096,9 +1101,9 @@ fn create_pattern_walkers(sources: &Sources) -> Vec<WalkBuilder> {
// …and no later `@source not` directive excludes the directory for all of
// those patterns. When a pattern comes after the `not`, keep walking; the
// file-level check below resolves the conflict exactly.
return !filter_nots.iter().any(|not| {
not.covers_dir(path) && relevant.iter().all(|p| p.idx < not.idx)
});
return !filter_nots
.iter()
.any(|not| not.matches(path) && relevant.iter().all(|p| p.idx < not.idx));
}
let rel = rooted_posix(remainder);
@ -1106,7 +1111,7 @@ fn create_pattern_walkers(sources: &Sources) -> Vec<WalkBuilder> {
glob_match(&p.pattern, rel.as_bytes())
&& !filter_nots
.iter()
.any(|not| not.idx > p.idx && not.matches_file(path))
.any(|not| not.idx > p.idx && not.matches(path))
&& (p.pins_extension || !is_ignored_by_default_file_rules(path))
})
});