From 993c660680953547da35755328054a7296f100a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=ABSergei?= Date: Thu, 14 May 2026 18:38:45 +0300 Subject: [PATCH 1/2] fix(oxide): scan explicit sources ignored by allowlist gitignore Fixes #19844 --- crates/oxide/src/scanner/mod.rs | 61 +++++++++++++++++++++++++++++++++ crates/oxide/tests/scanner.rs | 23 +++++++++++++ 2 files changed, 84 insertions(+) diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 30724f162188..1a0048411fea 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -18,6 +18,7 @@ use ignore::{gitignore::GitignoreBuilder, WalkBuilder}; use init_tracing::{init_tracing, SHOULD_TRACE}; use rayon::prelude::*; use std::collections::{BTreeMap, BTreeSet}; +use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; @@ -646,6 +647,12 @@ fn create_walker(sources: &Sources) -> Option { } else { other_roots.insert(base); } + if is_ignored_by_allowlist_gitignore(base) { + ignores + .entry(base) + .or_default() + .insert("!/**/*".to_string()); + } } SourceEntry::Pattern { base, pattern } => { let mut pattern = pattern.to_string(); @@ -848,6 +855,60 @@ fn create_walker(sources: &Sources) -> Option { Some(builder) } +fn is_ignored_by_allowlist_gitignore(path: &Path) -> bool { + let Some(root) = path.ancestors().find(|parent| parent.join(".git").exists()) else { + return false; + }; + + let ignore_file = root.join(".gitignore"); + if !ignore_file.exists() { + return false; + } + + let Ok(ignore_content) = fs::read_to_string(&ignore_file) else { + return false; + }; + if !gitignore_is_allowlist(&ignore_content) { + return false; + } + + let mut builder = GitignoreBuilder::new(root); + if builder.add(&ignore_file).is_some() { + return false; + } + + let Ok(ignore) = builder.build() else { + return false; + }; + let matched = ignore.matched_path_or_any_parents(path, true); + matched.is_ignore() +} + +fn gitignore_is_allowlist(contents: &str) -> bool { + let mut has_star = false; + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if line.starts_with('!') { + if has_star { + return true; + } + + continue; + } + + if line == "*" { + has_star = true; + continue; + } + } + + false +} + #[cfg(test)] mod tests { use super::{ChangedContent, Scanner}; diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 4003ff253048..a87a19a23d49 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -1412,6 +1412,29 @@ mod scanner { assert_eq!(candidates, vec!["content-['index.html']"]); } + #[test] + fn test_explicit_source_can_override_allowlist_with_unwhitelisted_vendor() { + let ScanResult { + candidates, files, .. + } = scan_with_globs( + &[ + (".gitignore", "*\n!/app\n!/app/design/**"), + ("app/design/index.html", "content-['app/design/index.html']"), + ( + "vendor/acme/theme/templates/component.phtml", + "content-['vendor/acme/theme/templates/component.phtml']", + ), + ], + vec!["@source './vendor/acme/theme'"], + ); + + assert_eq!( + candidates, + vec!["content-['vendor/acme/theme/templates/component.phtml']"] + ); + assert_eq!(files, vec!["vendor/acme/theme/templates/component.phtml"]); + } + #[test] fn test_allow_explicit_node_modules_paths() { // Create a temporary working directory From 62e352466a193da4cb1e7df75a4b358277e2c33b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=ABSergei?= Date: Fri, 15 May 2026 14:42:32 +0300 Subject: [PATCH 2/2] fix(oxide): preserve binary ignores for allowlist sources --- crates/oxide/src/scanner/mod.rs | 15 +++++++++------ crates/oxide/tests/scanner.rs | 4 ++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 1a0048411fea..9097bdd94554 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -648,10 +648,9 @@ fn create_walker(sources: &Sources) -> Option { other_roots.insert(base); } if is_ignored_by_allowlist_gitignore(base) { - ignores - .entry(base) - .or_default() - .insert("!/**/*".to_string()); + let patterns = ignores.entry(base).or_default(); + patterns.insert("!/**/*".to_string()); + patterns.insert(BINARY_EXTENSIONS_GLOB.clone()); } } SourceEntry::Pattern { base, pattern } => { @@ -855,6 +854,7 @@ fn create_walker(sources: &Sources) -> Option { Some(builder) } +/// Returns whether a path is ignored by a root `.gitignore` that starts as an allow-list. fn is_ignored_by_allowlist_gitignore(path: &Path) -> bool { let Some(root) = path.ancestors().find(|parent| parent.join(".git").exists()) else { return false; @@ -873,8 +873,10 @@ fn is_ignored_by_allowlist_gitignore(path: &Path) -> bool { } let mut builder = GitignoreBuilder::new(root); - if builder.add(&ignore_file).is_some() { - return false; + for line in ignore_content.lines() { + if builder.add_line(Some(ignore_file.clone()), line).is_err() { + return false; + } } let Ok(ignore) = builder.build() else { @@ -884,6 +886,7 @@ fn is_ignored_by_allowlist_gitignore(path: &Path) -> bool { matched.is_ignore() } +/// Returns whether a `.gitignore` uses a bare `*` followed by negated allow-list rules. fn gitignore_is_allowlist(contents: &str) -> bool { let mut has_star = false; for line in contents.lines() { diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index a87a19a23d49..3138c9de10a5 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -1424,6 +1424,10 @@ mod scanner { "vendor/acme/theme/templates/component.phtml", "content-['vendor/acme/theme/templates/component.phtml']", ), + ( + "vendor/acme/theme/templates/image.png", + "content-['vendor/acme/theme/templates/image.png']", + ), ], vec!["@source './vendor/acme/theme'"], );