diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 30724f162188..9097bdd94554 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,11 @@ fn create_walker(sources: &Sources) -> Option { } else { other_roots.insert(base); } + if is_ignored_by_allowlist_gitignore(base) { + let patterns = ignores.entry(base).or_default(); + patterns.insert("!/**/*".to_string()); + patterns.insert(BINARY_EXTENSIONS_GLOB.clone()); + } } SourceEntry::Pattern { base, pattern } => { let mut pattern = pattern.to_string(); @@ -848,6 +854,64 @@ 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; + }; + + 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); + 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 { + return false; + }; + let matched = ignore.matched_path_or_any_parents(path, true); + 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() { + 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..3138c9de10a5 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -1412,6 +1412,33 @@ 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']", + ), + ( + "vendor/acme/theme/templates/image.png", + "content-['vendor/acme/theme/templates/image.png']", + ), + ], + 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