From d05a718be5f4427da4aced6948362abc7d9f6c5a Mon Sep 17 00:00:00 2001 From: Ami Fischman Date: Fri, 28 Nov 2025 11:44:45 -0800 Subject: [PATCH 1/4] `--ignore-contain`: ignore directories containing a named entry (e.g. [`CACHEDIR.TAG`](https://bford.info/cachedir/)). Fixes #1727. --- CHANGELOG.md | 2 +- src/cli.rs | 10 ++++++++++ src/config.rs | 3 +++ src/main.rs | 1 + src/walk.rs | 8 ++++++++ tests/tests.rs | 18 ++++++++++++++++++ 6 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44555fb5..87288f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Upcoming release ## Features - +- Add `--ignore-contain` option to ignore directories containing a named entry (e.g. to ignore [`CACHEDIR.TAG`](https://bford.info/cachedir/)); see #1727 (@fischman). ## Bugfixes diff --git a/src/cli.rs b/src/cli.rs index d5174689..33114840 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -528,6 +528,16 @@ pub struct Opts { )] pub hyperlink: HyperlinkWhen, + /// Ignore directories containing the named entry. + #[arg( + long, + value_name = "name", + hide_short_help = true, + help = "Ignore directories containing this name", + long_help + )] + pub ignore_contain: Option, + /// Set number of threads to use for searching & executing (default: number /// of available CPU cores) #[arg(long, short = 'j', value_name = "num", hide_short_help = true, value_parser = str::parse::)] diff --git a/src/config.rs b/src/config.rs index 9e18120c..66f4c6bc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -130,6 +130,9 @@ pub struct Config { /// Whether or not to use hyperlinks on paths pub hyperlink: bool, + + /// A name that should stop traversal down its parent. (e.g. https://bford.info/cachedir/). + pub ignore_contain: Option, } impl Config { diff --git a/src/main.rs b/src/main.rs index fafb3b90..80e380fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -326,6 +326,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result Date: Tue, 13 Jan 2026 16:16:11 -0800 Subject: [PATCH 2/4] Fixes for tmccombs review comments. --- doc/fd.1 | 4 ++++ src/cli.rs | 7 ++----- src/config.rs | 4 ++-- src/walk.rs | 21 ++++++++++----------- tests/tests.rs | 26 +++++++++++++++++++++----- 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/doc/fd.1 b/doc/fd.1 index df42b172..278c4297 100644 --- a/doc/fd.1 +++ b/doc/fd.1 @@ -265,6 +265,10 @@ Examples: \-\-exclude '*.pyc' \-\-exclude node_modules .TP +.BI "\-\-ignore-contain " name +Exclude directories that (directly) contain the given name. +This option can be specified multiple times. +.TP .BI "\-\-ignore-file " path Add a custom ignore-file in '.gitignore' format. These files have a low precedence. diff --git a/src/cli.rs b/src/cli.rs index 33114840..c9233808 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -531,12 +531,9 @@ pub struct Opts { /// Ignore directories containing the named entry. #[arg( long, - value_name = "name", - hide_short_help = true, - help = "Ignore directories containing this name", - long_help + value_name = "name" )] - pub ignore_contain: Option, + pub ignore_contain: Vec, /// Set number of threads to use for searching & executing (default: number /// of available CPU cores) diff --git a/src/config.rs b/src/config.rs index 66f4c6bc..708a9933 100644 --- a/src/config.rs +++ b/src/config.rs @@ -131,8 +131,8 @@ pub struct Config { /// Whether or not to use hyperlinks on paths pub hyperlink: bool, - /// A name that should stop traversal down its parent. (e.g. https://bford.info/cachedir/). - pub ignore_contain: Option, + /// Names that should stop traversal down their parent. (e.g. https://bford.info/cachedir/). + pub ignore_contain: Vec, } impl Config { diff --git a/src/walk.rs b/src/walk.rs index 0ff6ebeb..a07b725f 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -498,15 +498,22 @@ impl WorkerState { } }; + // Check the depth & name first, since they don't require metadata. + let entry_path = entry.path(); + + // Filter out directories containing a given name. + if entry_path.is_dir() + && config.ignore_contain.iter().any(|ic| entry_path.join(ic).exists()) + { + return WalkState::Skip; + } + if let Some(min_depth) = config.min_depth && entry.depth().is_none_or(|d| d < min_depth) { return WalkState::Continue; } - // Check the name first, since it doesn't require metadata - let entry_path = entry.path(); - let search_str: Cow = if config.search_full_path { let path_abs_buf = filesystem::path_absolute_form(entry_path) .expect("Retrieving absolute path succeeds"); @@ -560,14 +567,6 @@ impl WorkerState { } } - // Filter out directories containing a given name. - if let Some(ref ignore_contain) = config.ignore_contain - && entry_path.is_dir() - && entry_path.join(ignore_contain).exists() - { - return WalkState::Skip; - } - // Filter out unwanted sizes if it is a file and we have been given size constraints. if !config.size_constraints.is_empty() { if entry_path.is_file() { diff --git a/tests/tests.rs b/tests/tests.rs index fe7dd682..59017b0d 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2710,6 +2710,25 @@ fn test_hyperlink() { #[test] fn test_ignore_contain() { + let te = TestEnv::new( + &["include", "exclude", "exclude/sub", "other"], + &[ + "top", + "include/foo", + "exclude/CACHEDIR.TAG", + "exclude/sub/nope", + "other/ignoremyparent", + ], + ); + let expected = "include/ + include/foo + symlink + top"; + te.assert_output(&["--ignore-contain=CACHEDIR.TAG", "--ignore-contain=ignoremyparent", "."], expected); +} + +#[test] +fn test_ignore_contain_has_highest_precedence() { let te = TestEnv::new( &["include", "exclude", "exclude/sub"], &[ @@ -2719,9 +2738,6 @@ fn test_ignore_contain() { "exclude/sub/nope", ], ); - let expected = "include/ - include/foo - symlink - top"; - te.assert_output(&["--ignore-contain=CACHEDIR.TAG", "."], expected); + let expected = "include/foo"; + te.assert_output(&["--ignore-contain=CACHEDIR.TAG", "--min-depth=2", "."], expected); } From 1df183e0cdc84404737961eae6505204544ecef9 Mon Sep 17 00:00:00 2001 From: Ami Fischman Date: Tue, 13 Jan 2026 16:37:11 -0800 Subject: [PATCH 3/4] cargo fmt --- src/cli.rs | 5 +---- src/walk.rs | 5 ++++- tests/tests.rs | 14 ++++++++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index c9233808..4dbe547e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -529,10 +529,7 @@ pub struct Opts { pub hyperlink: HyperlinkWhen, /// Ignore directories containing the named entry. - #[arg( - long, - value_name = "name" - )] + #[arg(long, value_name = "name")] pub ignore_contain: Vec, /// Set number of threads to use for searching & executing (default: number diff --git a/src/walk.rs b/src/walk.rs index a07b725f..8a32409a 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -503,7 +503,10 @@ impl WorkerState { // Filter out directories containing a given name. if entry_path.is_dir() - && config.ignore_contain.iter().any(|ic| entry_path.join(ic).exists()) + && config + .ignore_contain + .iter() + .any(|ic| entry_path.join(ic).exists()) { return WalkState::Skip; } diff --git a/tests/tests.rs b/tests/tests.rs index 59017b0d..fc714273 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2724,7 +2724,14 @@ fn test_ignore_contain() { include/foo symlink top"; - te.assert_output(&["--ignore-contain=CACHEDIR.TAG", "--ignore-contain=ignoremyparent", "."], expected); + te.assert_output( + &[ + "--ignore-contain=CACHEDIR.TAG", + "--ignore-contain=ignoremyparent", + ".", + ], + expected, + ); } #[test] @@ -2739,5 +2746,8 @@ fn test_ignore_contain_has_highest_precedence() { ], ); let expected = "include/foo"; - te.assert_output(&["--ignore-contain=CACHEDIR.TAG", "--min-depth=2", "."], expected); + te.assert_output( + &["--ignore-contain=CACHEDIR.TAG", "--min-depth=2", "."], + expected, + ); } From f3a8bb7ce6826f50195db66a68770df39eb62989 Mon Sep 17 00:00:00 2001 From: Ami Fischman Date: Wed, 14 Jan 2026 07:58:30 -0800 Subject: [PATCH 4/4] ignore_contain now has higher precedence than root check. --- src/walk.rs | 31 ++++++++++++++++--------------- tests/tests.rs | 9 ++++++++- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index 8a32409a..b37cc835 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -461,11 +461,22 @@ impl WorkerState { return WalkState::Quit; } - let entry = match entry { - Ok(ref e) if e.depth() == 0 => { + 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 { // Skip the root directory entry. return WalkState::Continue; } + } + let entry = match entry { Ok(e) => DirEntry::normal(e), Err(ignore::Error::WithPath { path, @@ -498,25 +509,15 @@ impl WorkerState { } }; - // Check the depth & name first, since they don't require metadata. - let entry_path = entry.path(); - - // Filter out directories containing a given name. - if entry_path.is_dir() - && config - .ignore_contain - .iter() - .any(|ic| entry_path.join(ic).exists()) - { - return WalkState::Skip; - } - if let Some(min_depth) = config.min_depth && entry.depth().is_none_or(|d| d < min_depth) { return WalkState::Continue; } + // Check the name first, since it doesn't require metadata + let entry_path = entry.path(); + let search_str: Cow = if config.search_full_path { let path_abs_buf = filesystem::path_absolute_form(entry_path) .expect("Retrieving absolute path succeeds"); diff --git a/tests/tests.rs b/tests/tests.rs index fc714273..c6db5929 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2735,7 +2735,7 @@ fn test_ignore_contain() { } #[test] -fn test_ignore_contain_has_highest_precedence() { +fn test_ignore_contain_precedence_over_depth_check() { let te = TestEnv::new( &["include", "exclude", "exclude/sub"], &[ @@ -2751,3 +2751,10 @@ fn test_ignore_contain_has_highest_precedence() { expected, ); } + +#[test] +fn test_ignore_contain_precedence_over_root_check() { + let te = TestEnv::new(&["include"], &["CACHEDIR.TAG", "top", "include/foo"]); + let expected = ""; + te.assert_output(&["--ignore-contain=CACHEDIR.TAG", "."], expected); +}