diff --git a/src/cli.rs b/src/cli.rs index 4dbe547e0..9ca630642 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -305,6 +305,22 @@ pub struct Opts { )] pub exclude: Vec, + /// Force-include entries matching the given glob pattern, even if they + /// would otherwise be ignored by '.gitignore' files. This overrides + /// VCS ignore rules for specific patterns while keeping them active + /// for everything else. Multiple patterns can be specified. + /// + /// Examples: + /// {n} --override-ignore node_modules + /// {n} --override-ignore '*.log' + #[arg( + long, + value_name = "pattern", + help = "Override .gitignore for entries matching the given glob pattern", + long_help + )] + pub override_ignore: Vec, + /// Do not traverse into directories that match the search criteria. If /// you want to exclude specific directories, use the '--exclude=…' option. #[arg(long, hide_short_help = true, conflicts_with_all(&["size", "exact_depth"]), diff --git a/src/config.rs b/src/config.rs index 708a99333..38850aa0a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -100,6 +100,9 @@ pub struct Config { /// A list of glob patterns that should be excluded from the search. pub exclude_patterns: Vec, + /// A list of glob patterns that should be force-included even when gitignored. + pub override_ignore_patterns: Vec, + /// A list of custom ignore files. pub ignore_files: Vec, diff --git a/src/main.rs b/src/main.rs index 80e380fe9..9212a294b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -316,6 +316,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result Result { + fn build_walker(&self, paths: &[PathBuf], respect_vcs_ignore: bool) -> Result { let first_path = &paths[0]; let config = &self.config; let overrides = self.build_overrides(paths)?; + let use_vcs_ignore = respect_vcs_ignore && config.read_vcsignore; + let mut builder = WalkBuilder::new(first_path); builder .hidden(config.ignore_hidden) .ignore(config.read_fdignore) - .parents(config.read_parent_ignore && (config.read_fdignore || config.read_vcsignore)) - .git_ignore(config.read_vcsignore) - .git_global(config.read_vcsignore) - .git_exclude(config.read_vcsignore) - .require_git(config.require_git_to_read_vcsignore) + .parents(config.read_parent_ignore && (config.read_fdignore || use_vcs_ignore)) + .git_ignore(use_vcs_ignore) + .git_global(use_vcs_ignore) + .git_exclude(use_vcs_ignore) + .require_git(respect_vcs_ignore && config.require_git_to_read_vcsignore) .overrides(overrides) .follow_links(config.follow_links) // No need to check for supported platforms, option is unavailable on unsupported ones @@ -403,6 +406,231 @@ impl WorkerState { Ok(walker) } + /// Build an Override matcher for checking if entries match --override-ignore patterns. + fn build_override_matcher(&self, paths: &[PathBuf]) -> Result { + let first_path = &paths[0]; + let mut builder = OverrideBuilder::new(first_path); + for pattern in &self.config.override_ignore_patterns { + builder + .add(pattern) + .map_err(|e| anyhow!("Malformed override-ignore pattern: {}", e))?; + // Also match contents inside directories matching the pattern + let dir_contents = format!("{pattern}/**"); + builder + .add(&dir_contents) + .map_err(|e| anyhow!("Malformed override-ignore pattern: {}", e))?; + } + builder + .build() + .map_err(|_| anyhow!("Mismatch in override-ignore patterns")) + } + + /// Spawn sender threads for the override walk (walk 2). + /// Only emits entries that match override-ignore patterns and weren't already seen. + fn spawn_override_senders( + &self, + walker: WalkParallel, + tx: Sender, + seen_paths: &Mutex>, + override_matcher: &Override, + ) { + walker.run(|| { + let patterns = &self.patterns; + let config = &self.config; + let quit_flag = self.quit_flag.as_ref(); + + let mut limit = 0x100; + if let Some(cmd) = &config.command + && !cmd.in_batch_mode() + && config.threads > 1 + { + limit = 1; + } + let mut tx = BatchSender::new(tx.clone(), limit); + + Box::new(move |entry| { + if quit_flag.load(Ordering::Relaxed) { + return WalkState::Quit; + } + + if let Ok(e) = &entry { + let entry_path = e.path(); + if entry_path.is_dir() + && config + .ignore_contain + .iter() + .any(|ic| entry_path.join(ic).exists()) + { + return WalkState::Skip; + } + if e.depth() == 0 { + return WalkState::Continue; + } + } + + let entry = match entry { + Ok(e) => DirEntry::normal(e), + Err(ignore::Error::WithPath { + path, + err: inner_err, + }) => match inner_err.as_ref() { + ignore::Error::Io(io_error) + if io_error.kind() == io::ErrorKind::NotFound + && path + .symlink_metadata() + .ok() + .is_some_and(|m| m.file_type().is_symlink()) => + { + DirEntry::broken_symlink(path) + } + _ => { + return match tx.send(WorkerResult::Error(ignore::Error::WithPath { + path, + err: inner_err, + })) { + Ok(_) => WalkState::Continue, + Err(_) => WalkState::Quit, + }; + } + }, + Err(err) => { + return match tx.send(WorkerResult::Error(err)) { + Ok(_) => WalkState::Continue, + Err(_) => WalkState::Quit, + }; + } + }; + + // Check override-ignore pattern match. + // Return Continue (not Skip) so directories are still traversed + // even when they don't match — files inside them might match. + let entry_path = entry.path(); + let is_dir = entry_path.is_dir(); + if !override_matcher.matched(entry_path, is_dir).is_whitelist() { + return WalkState::Continue; + } + + // Dedup: skip entries already found in walk 1 + if seen_paths + .lock() + .unwrap() + .contains(&entry_path.to_path_buf()) + { + return WalkState::Continue; + } + + if let Some(min_depth) = config.min_depth + && entry.depth().is_none_or(|d| d < min_depth) + { + return WalkState::Continue; + } + + let search_str: Cow = if config.search_full_path { + let path_abs_buf = filesystem::path_absolute_form(entry_path) + .expect("Retrieving absolute path succeeds"); + Cow::Owned(path_abs_buf.as_os_str().to_os_string()) + } else { + match entry_path.file_name() { + Some(filename) => Cow::Borrowed(filename), + None => unreachable!( + "Encountered file system entry without a file name. This should only \ + happen for paths like 'foo/bar/..' or '/' which are not supposed to \ + appear in a file system traversal." + ), + } + }; + + if !patterns + .iter() + .all(|pat| pat.is_match(&filesystem::osstr_to_bytes(search_str.as_ref()))) + { + return WalkState::Continue; + } + + if let Some(ref exts_regex) = config.extensions { + if let Some(path_str) = entry_path.file_name() { + if !exts_regex.is_match(&filesystem::osstr_to_bytes(path_str)) { + return WalkState::Continue; + } + } else { + return WalkState::Continue; + } + } + + if let Some(ref file_types) = config.file_types + && file_types.should_ignore(&entry) + { + return WalkState::Continue; + } + + #[cfg(unix)] + { + if let Some(ref owner_constraint) = config.owner_constraint { + if let Some(metadata) = entry.metadata() { + if !owner_constraint.matches(metadata) { + return WalkState::Continue; + } + } else { + return WalkState::Continue; + } + } + } + + if !config.size_constraints.is_empty() { + if entry_path.is_file() { + if let Some(metadata) = entry.metadata() { + let file_size = metadata.len(); + if config + .size_constraints + .iter() + .any(|sc| !sc.is_within(file_size)) + { + return WalkState::Continue; + } + } else { + return WalkState::Continue; + } + } else { + return WalkState::Continue; + } + } + + if !config.time_constraints.is_empty() { + let mut matched = false; + if let Some(metadata) = entry.metadata() + && let Ok(modified) = metadata.modified() + { + matched = config + .time_constraints + .iter() + .all(|tf| tf.applies_to(&modified)); + } + if !matched { + return WalkState::Continue; + } + } + + if config.is_printing() + && let Some(ls_colors) = &config.ls_colors + { + entry.style(ls_colors); + } + + let send_result = tx.send(WorkerResult::Entry(entry)); + + if send_result.is_err() { + return WalkState::Quit; + } + + if config.prune { + return WalkState::Skip; + } + + WalkState::Continue + }) + }); + } + /// Run the receiver work, either on this thread or a pool of background /// threads (for --exec). fn receive(&self, rx: Receiver) -> ExitCode { @@ -440,7 +668,12 @@ impl WorkerState { } /// Spawn the sender threads. - fn spawn_senders(&self, walker: WalkParallel, tx: Sender) { + fn spawn_senders( + &self, + walker: WalkParallel, + tx: Sender, + seen_paths: Option<&Mutex>>, + ) { walker.run(|| { let patterns = &self.patterns; let config = &self.config; @@ -614,6 +847,11 @@ impl WorkerState { entry.style(ls_colors); } + // Track seen paths for dedup with override walk + if let Some(seen) = seen_paths { + seen.lock().unwrap().insert(entry.path().to_path_buf()); + } + let send_result = tx.send(WorkerResult::Entry(entry)); if send_result.is_err() { @@ -633,7 +871,7 @@ impl WorkerState { /// Perform the recursive scan. fn scan(&self, paths: &[PathBuf]) -> Result { let config = &self.config; - let walker = self.build_walker(paths)?; + let walker = self.build_walker(paths, true)?; if config.ls_colors.is_some() && config.is_printing() { let quit_flag = Arc::clone(&self.quit_flag); @@ -650,14 +888,41 @@ impl WorkerState { .unwrap(); } + let has_overrides = !config.override_ignore_patterns.is_empty(); + let seen_paths: Option>> = if has_overrides { + Some(Mutex::new(HashSet::new())) + } else { + None + }; + let (tx, rx) = bounded(2 * config.threads); let exit_code = thread::scope(|scope| { // Spawn the receiver thread(s) let receiver = scope.spawn(|| self.receive(rx)); - // Spawn the sender threads. - self.spawn_senders(walker, tx); + // Walk 1: normal walk (respects all ignore rules) + let tx1 = tx.clone(); + self.spawn_senders(walker, tx1, seen_paths.as_ref()); + + // Walk 2: override walk (disables VCS ignores, only emits + // entries matching --override-ignore patterns not seen in walk 1) + if has_overrides + && !self.quit_flag.load(Ordering::Relaxed) + && let Ok(override_walker) = self.build_walker(paths, false) + && let Ok(override_matcher) = self.build_override_matcher(paths) + { + let tx2 = tx.clone(); + self.spawn_override_senders( + override_walker, + tx2, + seen_paths.as_ref().unwrap(), + &override_matcher, + ); + } + + // Drop our copy of tx to signal receiver we're done + drop(tx); receiver.join().unwrap() }); diff --git a/tests/tests.rs b/tests/tests.rs index c125d3a59..2ae26c942 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2758,3 +2758,134 @@ fn test_ignore_contain_precedence_over_root_check() { let expected = ""; te.assert_output(&["--ignore-contain=CACHEDIR.TAG", "."], expected); } + +/// --override-ignore: basic directory override +#[test] +fn test_override_ignore_directory() { + let dirs = &["ignored_dir"]; + let files = &["ignored_dir/target.txt", "normal.txt"]; + let te = TestEnv::new(dirs, files); + + // Add ignored_dir to .gitignore + fs::File::create(te.test_root().join(".gitignore")) + .unwrap() + .write_all(b"ignored_dir") + .unwrap(); + + // Without override: ignored_dir is not searched + te.assert_output(&["txt"], "normal.txt"); + + // With override: ignored_dir IS searched + te.assert_output( + &["--override-ignore", "ignored_dir", "txt"], + "ignored_dir/target.txt + normal.txt", + ); +} + +/// --override-ignore: multiple patterns +#[test] +fn test_override_ignore_multiple_patterns() { + let dirs = &["build", "cache"]; + let files = &["build/out.txt", "cache/data.txt", "src.txt"]; + let te = TestEnv::new(dirs, files); + + fs::File::create(te.test_root().join(".gitignore")) + .unwrap() + .write_all(b"build\ncache") + .unwrap(); + + // Without override: both ignored + te.assert_output(&["txt"], "src.txt"); + + // Override only build + te.assert_output( + &["--override-ignore", "build", "txt"], + "build/out.txt + src.txt", + ); + + // Override both + te.assert_output( + &[ + "--override-ignore", + "build", + "--override-ignore", + "cache", + "txt", + ], + "build/out.txt + cache/data.txt + src.txt", + ); +} + +/// --override-ignore combined with --exclude +#[test] +fn test_override_ignore_with_exclude() { + let dirs = &["ignored_dir"]; + let files = &["ignored_dir/keep.txt", "ignored_dir/skip.log", "normal.txt"]; + let te = TestEnv::new(dirs, files); + + fs::File::create(te.test_root().join(".gitignore")) + .unwrap() + .write_all(b"ignored_dir") + .unwrap(); + + // Override gitignore for ignored_dir, but exclude *.log + te.assert_output( + &[ + "--override-ignore", + "ignored_dir", + "--exclude", + "*.log", + "txt", + ], + "ignored_dir/keep.txt + normal.txt", + ); +} + +/// --override-ignore with glob patterns +#[test] +fn test_override_ignore_glob_pattern() { + let dirs = &["logs"]; + let files = &["logs/app.log", "logs/error.log", "main.txt"]; + let te = TestEnv::new(dirs, files); + + fs::File::create(te.test_root().join(".gitignore")) + .unwrap() + .write_all(b"logs") + .unwrap(); + + // Without override: logs directory is gitignored + te.assert_output(&["log"], ""); + + // Override with directory name: logs directory and contents now visible + te.assert_output( + &["--override-ignore", "logs", "log"], + "logs/ + logs/app.log + logs/error.log", + ); +} + +/// --override-ignore: gitignore still applies to non-overridden entries +#[test] +fn test_override_ignore_preserves_other_ignores() { + let dirs = &["node_modules", "dist"]; + let files = &["node_modules/pkg.js", "dist/bundle.js", "src.js"]; + let te = TestEnv::new(dirs, files); + + fs::File::create(te.test_root().join(".gitignore")) + .unwrap() + .write_all(b"node_modules\ndist") + .unwrap(); + + // Override only node_modules — dist should stay ignored + te.assert_output( + &["--override-ignore", "node_modules", "js"], + "node_modules/pkg.js + src.js", + ); +}