Skip to content

Commit e2f504e

Browse files
committed
feat: add --override-ignore flag to selectively bypass .gitignore rules
1 parent 40d8eb3 commit e2f504e

5 files changed

Lines changed: 426 additions & 10 deletions

File tree

src/cli.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,22 @@ pub struct Opts {
305305
)]
306306
pub exclude: Vec<String>,
307307

308+
/// Force-include entries matching the given glob pattern, even if they
309+
/// would otherwise be ignored by '.gitignore' files. This overrides
310+
/// VCS ignore rules for specific patterns while keeping them active
311+
/// for everything else. Multiple patterns can be specified.
312+
///
313+
/// Examples:
314+
/// {n} --override-ignore node_modules
315+
/// {n} --override-ignore '*.log'
316+
#[arg(
317+
long,
318+
value_name = "pattern",
319+
help = "Override .gitignore for entries matching the given glob pattern",
320+
long_help
321+
)]
322+
pub override_ignore: Vec<String>,
323+
308324
/// Do not traverse into directories that match the search criteria. If
309325
/// you want to exclude specific directories, use the '--exclude=…' option.
310326
#[arg(long, hide_short_help = true, conflicts_with_all(&["size", "exact_depth"]),

src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ pub struct Config {
100100
/// A list of glob patterns that should be excluded from the search.
101101
pub exclude_patterns: Vec<String>,
102102

103+
/// A list of glob patterns that should be force-included even when gitignored.
104+
pub override_ignore_patterns: Vec<String>,
105+
103106
/// A list of custom ignore files.
104107
pub ignore_files: Vec<PathBuf>,
105108

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result<Config
316316
command: command.map(Arc::new),
317317
batch_size: opts.batch_size,
318318
exclude_patterns: opts.exclude.iter().map(|p| String::from("!") + p).collect(),
319+
override_ignore_patterns: std::mem::take(&mut opts.override_ignore),
319320
ignore_files: std::mem::take(&mut opts.ignore_file),
320321
size_constraints: size_limits,
321322
time_constraints,

src/walk.rs

Lines changed: 275 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::borrow::Cow;
2+
use std::collections::HashSet;
23
use std::ffi::OsStr;
34
use std::io::{self, Write};
45
use std::mem;
@@ -344,20 +345,22 @@ impl WorkerState {
344345
.map_err(|_| anyhow!("Mismatch in exclude patterns"))
345346
}
346347

347-
fn build_walker(&self, paths: &[PathBuf]) -> Result<WalkParallel> {
348+
fn build_walker(&self, paths: &[PathBuf], respect_vcs_ignore: bool) -> Result<WalkParallel> {
348349
let first_path = &paths[0];
349350
let config = &self.config;
350351
let overrides = self.build_overrides(paths)?;
351352

353+
let use_vcs_ignore = respect_vcs_ignore && config.read_vcsignore;
354+
352355
let mut builder = WalkBuilder::new(first_path);
353356
builder
354357
.hidden(config.ignore_hidden)
355358
.ignore(config.read_fdignore)
356-
.parents(config.read_parent_ignore && (config.read_fdignore || config.read_vcsignore))
357-
.git_ignore(config.read_vcsignore)
358-
.git_global(config.read_vcsignore)
359-
.git_exclude(config.read_vcsignore)
360-
.require_git(config.require_git_to_read_vcsignore)
359+
.parents(config.read_parent_ignore && (config.read_fdignore || use_vcs_ignore))
360+
.git_ignore(use_vcs_ignore)
361+
.git_global(use_vcs_ignore)
362+
.git_exclude(use_vcs_ignore)
363+
.require_git(respect_vcs_ignore && config.require_git_to_read_vcsignore)
361364
.overrides(overrides)
362365
.follow_links(config.follow_links)
363366
// No need to check for supported platforms, option is unavailable on unsupported ones
@@ -403,6 +406,231 @@ impl WorkerState {
403406
Ok(walker)
404407
}
405408

409+
/// Build an Override matcher for checking if entries match --override-ignore patterns.
410+
fn build_override_matcher(&self, paths: &[PathBuf]) -> Result<Override> {
411+
let first_path = &paths[0];
412+
let mut builder = OverrideBuilder::new(first_path);
413+
for pattern in &self.config.override_ignore_patterns {
414+
builder
415+
.add(pattern)
416+
.map_err(|e| anyhow!("Malformed override-ignore pattern: {}", e))?;
417+
// Also match contents inside directories matching the pattern
418+
let dir_contents = format!("{pattern}/**");
419+
builder
420+
.add(&dir_contents)
421+
.map_err(|e| anyhow!("Malformed override-ignore pattern: {}", e))?;
422+
}
423+
builder
424+
.build()
425+
.map_err(|_| anyhow!("Mismatch in override-ignore patterns"))
426+
}
427+
428+
/// Spawn sender threads for the override walk (walk 2).
429+
/// Only emits entries that match override-ignore patterns and weren't already seen.
430+
fn spawn_override_senders(
431+
&self,
432+
walker: WalkParallel,
433+
tx: Sender<Batch>,
434+
seen_paths: &Mutex<HashSet<PathBuf>>,
435+
override_matcher: &Override,
436+
) {
437+
walker.run(|| {
438+
let patterns = &self.patterns;
439+
let config = &self.config;
440+
let quit_flag = self.quit_flag.as_ref();
441+
442+
let mut limit = 0x100;
443+
if let Some(cmd) = &config.command
444+
&& !cmd.in_batch_mode()
445+
&& config.threads > 1
446+
{
447+
limit = 1;
448+
}
449+
let mut tx = BatchSender::new(tx.clone(), limit);
450+
451+
Box::new(move |entry| {
452+
if quit_flag.load(Ordering::Relaxed) {
453+
return WalkState::Quit;
454+
}
455+
456+
if let Ok(e) = &entry {
457+
let entry_path = e.path();
458+
if entry_path.is_dir()
459+
&& config
460+
.ignore_contain
461+
.iter()
462+
.any(|ic| entry_path.join(ic).exists())
463+
{
464+
return WalkState::Skip;
465+
}
466+
if e.depth() == 0 {
467+
return WalkState::Continue;
468+
}
469+
}
470+
471+
let entry = match entry {
472+
Ok(e) => DirEntry::normal(e),
473+
Err(ignore::Error::WithPath {
474+
path,
475+
err: inner_err,
476+
}) => match inner_err.as_ref() {
477+
ignore::Error::Io(io_error)
478+
if io_error.kind() == io::ErrorKind::NotFound
479+
&& path
480+
.symlink_metadata()
481+
.ok()
482+
.is_some_and(|m| m.file_type().is_symlink()) =>
483+
{
484+
DirEntry::broken_symlink(path)
485+
}
486+
_ => {
487+
return match tx.send(WorkerResult::Error(ignore::Error::WithPath {
488+
path,
489+
err: inner_err,
490+
})) {
491+
Ok(_) => WalkState::Continue,
492+
Err(_) => WalkState::Quit,
493+
};
494+
}
495+
},
496+
Err(err) => {
497+
return match tx.send(WorkerResult::Error(err)) {
498+
Ok(_) => WalkState::Continue,
499+
Err(_) => WalkState::Quit,
500+
};
501+
}
502+
};
503+
504+
// Check override-ignore pattern match.
505+
// Return Continue (not Skip) so directories are still traversed
506+
// even when they don't match — files inside them might match.
507+
let entry_path = entry.path();
508+
let is_dir = entry_path.is_dir();
509+
if !override_matcher.matched(entry_path, is_dir).is_whitelist() {
510+
return WalkState::Continue;
511+
}
512+
513+
// Dedup: skip entries already found in walk 1
514+
if seen_paths
515+
.lock()
516+
.unwrap()
517+
.contains(&entry_path.to_path_buf())
518+
{
519+
return WalkState::Continue;
520+
}
521+
522+
if let Some(min_depth) = config.min_depth
523+
&& entry.depth().is_none_or(|d| d < min_depth)
524+
{
525+
return WalkState::Continue;
526+
}
527+
528+
let search_str: Cow<OsStr> = if config.search_full_path {
529+
let path_abs_buf = filesystem::path_absolute_form(entry_path)
530+
.expect("Retrieving absolute path succeeds");
531+
Cow::Owned(path_abs_buf.as_os_str().to_os_string())
532+
} else {
533+
match entry_path.file_name() {
534+
Some(filename) => Cow::Borrowed(filename),
535+
None => unreachable!(
536+
"Encountered file system entry without a file name. This should only \
537+
happen for paths like 'foo/bar/..' or '/' which are not supposed to \
538+
appear in a file system traversal."
539+
),
540+
}
541+
};
542+
543+
if !patterns
544+
.iter()
545+
.all(|pat| pat.is_match(&filesystem::osstr_to_bytes(search_str.as_ref())))
546+
{
547+
return WalkState::Continue;
548+
}
549+
550+
if let Some(ref exts_regex) = config.extensions {
551+
if let Some(path_str) = entry_path.file_name() {
552+
if !exts_regex.is_match(&filesystem::osstr_to_bytes(path_str)) {
553+
return WalkState::Continue;
554+
}
555+
} else {
556+
return WalkState::Continue;
557+
}
558+
}
559+
560+
if let Some(ref file_types) = config.file_types
561+
&& file_types.should_ignore(&entry)
562+
{
563+
return WalkState::Continue;
564+
}
565+
566+
#[cfg(unix)]
567+
{
568+
if let Some(ref owner_constraint) = config.owner_constraint {
569+
if let Some(metadata) = entry.metadata() {
570+
if !owner_constraint.matches(metadata) {
571+
return WalkState::Continue;
572+
}
573+
} else {
574+
return WalkState::Continue;
575+
}
576+
}
577+
}
578+
579+
if !config.size_constraints.is_empty() {
580+
if entry_path.is_file() {
581+
if let Some(metadata) = entry.metadata() {
582+
let file_size = metadata.len();
583+
if config
584+
.size_constraints
585+
.iter()
586+
.any(|sc| !sc.is_within(file_size))
587+
{
588+
return WalkState::Continue;
589+
}
590+
} else {
591+
return WalkState::Continue;
592+
}
593+
} else {
594+
return WalkState::Continue;
595+
}
596+
}
597+
598+
if !config.time_constraints.is_empty() {
599+
let mut matched = false;
600+
if let Some(metadata) = entry.metadata()
601+
&& let Ok(modified) = metadata.modified()
602+
{
603+
matched = config
604+
.time_constraints
605+
.iter()
606+
.all(|tf| tf.applies_to(&modified));
607+
}
608+
if !matched {
609+
return WalkState::Continue;
610+
}
611+
}
612+
613+
if config.is_printing()
614+
&& let Some(ls_colors) = &config.ls_colors
615+
{
616+
entry.style(ls_colors);
617+
}
618+
619+
let send_result = tx.send(WorkerResult::Entry(entry));
620+
621+
if send_result.is_err() {
622+
return WalkState::Quit;
623+
}
624+
625+
if config.prune {
626+
return WalkState::Skip;
627+
}
628+
629+
WalkState::Continue
630+
})
631+
});
632+
}
633+
406634
/// Run the receiver work, either on this thread or a pool of background
407635
/// threads (for --exec).
408636
fn receive(&self, rx: Receiver<Batch>) -> ExitCode {
@@ -440,7 +668,12 @@ impl WorkerState {
440668
}
441669

442670
/// Spawn the sender threads.
443-
fn spawn_senders(&self, walker: WalkParallel, tx: Sender<Batch>) {
671+
fn spawn_senders(
672+
&self,
673+
walker: WalkParallel,
674+
tx: Sender<Batch>,
675+
seen_paths: Option<&Mutex<HashSet<PathBuf>>>,
676+
) {
444677
walker.run(|| {
445678
let patterns = &self.patterns;
446679
let config = &self.config;
@@ -614,6 +847,11 @@ impl WorkerState {
614847
entry.style(ls_colors);
615848
}
616849

850+
// Track seen paths for dedup with override walk
851+
if let Some(seen) = seen_paths {
852+
seen.lock().unwrap().insert(entry.path().to_path_buf());
853+
}
854+
617855
let send_result = tx.send(WorkerResult::Entry(entry));
618856

619857
if send_result.is_err() {
@@ -633,7 +871,7 @@ impl WorkerState {
633871
/// Perform the recursive scan.
634872
fn scan(&self, paths: &[PathBuf]) -> Result<ExitCode> {
635873
let config = &self.config;
636-
let walker = self.build_walker(paths)?;
874+
let walker = self.build_walker(paths, true)?;
637875

638876
if config.ls_colors.is_some() && config.is_printing() {
639877
let quit_flag = Arc::clone(&self.quit_flag);
@@ -650,14 +888,41 @@ impl WorkerState {
650888
.unwrap();
651889
}
652890

891+
let has_overrides = !config.override_ignore_patterns.is_empty();
892+
let seen_paths: Option<Mutex<HashSet<PathBuf>>> = if has_overrides {
893+
Some(Mutex::new(HashSet::new()))
894+
} else {
895+
None
896+
};
897+
653898
let (tx, rx) = bounded(2 * config.threads);
654899

655900
let exit_code = thread::scope(|scope| {
656901
// Spawn the receiver thread(s)
657902
let receiver = scope.spawn(|| self.receive(rx));
658903

659-
// Spawn the sender threads.
660-
self.spawn_senders(walker, tx);
904+
// Walk 1: normal walk (respects all ignore rules)
905+
let tx1 = tx.clone();
906+
self.spawn_senders(walker, tx1, seen_paths.as_ref());
907+
908+
// Walk 2: override walk (disables VCS ignores, only emits
909+
// entries matching --override-ignore patterns not seen in walk 1)
910+
if has_overrides
911+
&& !self.quit_flag.load(Ordering::Relaxed)
912+
&& let Ok(override_walker) = self.build_walker(paths, false)
913+
&& let Ok(override_matcher) = self.build_override_matcher(paths)
914+
{
915+
let tx2 = tx.clone();
916+
self.spawn_override_senders(
917+
override_walker,
918+
tx2,
919+
seen_paths.as_ref().unwrap(),
920+
&override_matcher,
921+
);
922+
}
923+
924+
// Drop our copy of tx to signal receiver we're done
925+
drop(tx);
661926

662927
receiver.join().unwrap()
663928
});

0 commit comments

Comments
 (0)