From c0ab06d5151c617a3a4df8efc424d7aa172ea7a7 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 12 Aug 2026 15:57:03 +0200 Subject: [PATCH] let later directory sources re-include earlier `@source not` exclusions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@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. --- crates/oxide/src/scanner/mod.rs | 101 ++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 3f87bc0a1..93cd7a84e 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -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 { .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 { + 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 { - // Collect all `@source not` directives with their positions - let nots: Vec = 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 = vec![];