let later directory sources re-include earlier @source not exclusions

`@source not "./src"` followed by `@source "./src"` scanned nothing:
in the auto/external walker, `@source not` directives were registered as
explicit gitignore layers, but plain directory sources emit no layer at
all, so there was nothing a later directive could win with — the exclusion
applied regardless of order. (A counter-whitelist layer would be wrong
too: it would rank above the `.gitignore` files on disk and bypass them
inside the re-included directory.)

Handle `@source not` in the auto/external walker with a filter closure
instead, mirroring how the pattern walkers already resolve ordering: the
last directive that covers a path wins. When that is a `@source not` the
entry is excluded; when it is a later auto/external source the entry falls
through to the normal gitignore + default rules handling, so re-included
directories keep their regular auto source semantics. Excluded directories
can still be pruned safely, because every auto/external base is its own
walk root and stays reachable even when it is nested inside an excluded
directory.

While moving the exclusion out of the gitignore layers, directory-shaped
directives now also have to exclude their base directory itself (the
normalized `/**/*` pattern only matches the directory's contents), so the
directory is pruned and doesn't widen the generated watch globs.
This commit is contained in:
Robin Malfait 2026-08-12 15:57:03 +02:00
parent 7536feb801
commit c0ab06d515
No known key found for this signature in database

View file

@ -55,7 +55,9 @@ use tracing::event;
// or another `@source` allows them.
//
// Later directives win over earlier ones on conflict: `@source not "./x"` followed by
// `@source "./x/keep.html"` scans `keep.html`, and vice versa excludes it.
// `@source "./x/keep.html"` scans `keep.html`, and vice versa excludes it. The same holds for
// directory sources: `@source not "./x"` followed by `@source "./x"` re-includes `./x` (with
// the normal auto source detection rules applied inside).
//
// # Implementation
//
@ -63,10 +65,11 @@ use tracing::event;
// never descend into directories that cannot contribute files). Sources are split over multiple
// walkers:
//
// - One walker for all `Auto` and `External` roots. Default rules and `@source not` rules are
// registered as explicit in-memory gitignores (they rank above `.gitignore` files found on
// disk, later directives above earlier ones). `External` roots additionally get a `!/**/*`
// whitelist (bypassing gitignore rules) plus a re-statement of the default rules.
// - One walker for all `Auto` and `External` roots. Default rules are registered as explicit
// in-memory gitignores (they rank above `.gitignore` files found on disk). `External` roots
// additionally get a `!/**/*` whitelist (bypassing gitignore rules) plus a re-statement of
// the default rules. `@source not` rules are handled by a filter closure that honors the
// directive order: the last directive covering a path wins.
//
// - One walker per `Pattern` base. It never looks at `.gitignore` files *above* its base
// (the static prefix is explicit), while `.gitignore` files inside the subtree still prune
@ -880,15 +883,57 @@ fn create_auto_walker(sources: &Sources) -> Option<WalkBuilder> {
.unwrap();
builder.add_gitignore(ignore_builder.build().unwrap());
}
SourceEntry::Ignored { base, pattern } => {
let mut ignore_builder = GitignoreBuilder::new(base);
ignore_builder.add_line(None, pattern).unwrap();
builder.add_gitignore(ignore_builder.build().unwrap());
}
_ => {}
}
}
// `@source not` directives are handled in a filter closure instead of a gitignore layer,
// because their effect depends on the directive order: the last directive that covers a
// path wins. When that is a `@source not`, the entry is excluded; when it is an
// auto/external source, the entry falls through to the normal gitignore + default rules
// handling. E.g.:
//
// ```css
// @source not "./src";
// @source "./src"; /* re-includes ./src, .gitignore files still apply inside */
// ```
//
// Directories excluded here can be pruned safely: every auto/external base is its own walk
// root, so a source root nested inside an excluded directory is still walked.
let includes: Vec<(usize, PathBuf)> = sources
.iter()
.enumerate()
.filter_map(|(idx, source)| match source {
SourceEntry::Auto { base, .. } => Some((idx, base.clone())),
_ => None,
})
.collect();
let nots = collect_not_rules(sources);
builder.filter_entry(move |entry| {
// Always keep the walk roots themselves
if entry.depth() == 0 {
return true;
}
let path = entry.path();
// The last `@source not` directive covering the path…
let Some(not_idx) = nots
.iter()
.filter(|not| not.matches(path))
.map(|not| not.idx)
.max()
else {
return true;
};
// …loses when a later auto/external source covers it as well
includes
.iter()
.any(|(idx, base)| *idx > not_idx && path.starts_with(base))
});
Some(builder)
}
@ -936,6 +981,12 @@ impl NotRule {
return false;
};
// A directory-shaped directive (normalized to a `/**/*` pattern) also excludes the
// base directory itself, not just its contents, so the directory can be pruned.
if remainder.as_os_str().is_empty() {
return self.pattern == "/**/*";
}
remainder.ancestors().any(|prefix| {
!prefix.as_os_str().is_empty()
&& glob_match(&self.pattern, rooted_posix(prefix).as_bytes())
@ -943,6 +994,22 @@ impl NotRule {
}
}
/// Collect all `@source not` directives with their positions.
fn collect_not_rules(sources: &Sources) -> Vec<NotRule> {
sources
.iter()
.enumerate()
.filter_map(|(idx, source)| match source {
SourceEntry::Ignored { base, pattern } => Some(NotRule {
idx,
base: base.clone(),
pattern: pattern.clone(),
}),
_ => None,
})
.collect()
}
/// Serialize a path relative to some base as a `/`-rooted posix style string, e.g.
/// `/ba*/index.html`, matching how source patterns are stored.
fn rooted_posix(path: &Path) -> String {
@ -998,19 +1065,7 @@ fn dir_could_contain_matches(pattern: &str, dir: &Path) -> bool {
/// Files matching a glob are whitelisted explicitly (a glob match beats file-level gitignore
/// and default rules). The filter closure then makes the exact per-file decision.
fn create_pattern_walkers(sources: &Sources) -> Vec<WalkBuilder> {
// Collect all `@source not` directives with their positions
let nots: Vec<NotRule> = sources
.iter()
.enumerate()
.filter_map(|(idx, source)| match source {
SourceEntry::Ignored { base, pattern } => Some(NotRule {
idx,
base: base.clone(),
pattern: pattern.clone(),
}),
_ => None,
})
.collect();
let nots = collect_not_rules(sources);
// Group patterns by base, preserving directive order
let mut bases: Vec<PathBuf> = vec![];