diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index 6b750d09c3..908da6fa67 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -14,6 +14,7 @@ use nucleo::Utf32String; use nucleo::pattern::CaseMatching; use nucleo::pattern::Normalization; use serde::Serialize; +use std::collections::HashSet; use std::num::NonZero; use std::path::Path; use std::path::PathBuf; @@ -430,15 +431,30 @@ fn walker_worker( override_matcher: Option, injector: Injector, ) { - let Some(first_root) = inner.search_directories.first() else { + let roots = &inner.search_directories; + let Some((first_root, other_roots)) = roots.split_first() else { let _ = inner.work_tx.send(WorkSignal::WalkComplete); return; }; let mut walk_builder = WalkBuilder::new(first_root); - for root in inner.search_directories.iter().skip(1) { - walk_builder.add(root); + let mut walk_roots = HashSet::from([first_root.as_path()]); + for root in other_roots { + if walk_roots.insert(root.as_path()) { + walk_builder.add(root); + } } + // Keep both walks for nested roots: ignore rules can expose different entries. + // Only those subtrees need to remember paths already sent to the matcher. + let seen_paths: Vec<_> = roots + .iter() + .map(|root| { + root.ancestors() + .skip(1) + .any(|parent| walk_roots.contains(parent)) + .then(|| Mutex::new(HashSet::new())) + }) + .collect(); walk_builder .threads(inner.threads) // Allow hidden entries. @@ -465,7 +481,7 @@ fn walker_worker( walker.run(|| { const CHECK_INTERVAL: usize = 1024; let mut n = 0; - let search_directories = inner.search_directories.clone(); + let seen_paths = &seen_paths; let injector = injector.clone(); let cancelled = inner.cancelled.clone(); let shutdown = inner.shutdown.clone(); @@ -479,7 +495,13 @@ fn walker_worker( if path.to_str().is_none() { return ignore::WalkState::Continue; } - if let Some((root_idx, relative_path)) = get_file_path(path, &search_directories) { + if let Some((root_idx, relative_path)) = get_file_path(path, roots) + && seen_paths[root_idx].as_ref().is_none_or(|seen| { + seen.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(PathBuf::from(relative_path)) + }) + { let match_type = match entry.file_type() { Some(file_type) if file_type.is_dir() => MatchType::Directory, _ => MatchType::File, diff --git a/codex-rs/file-search/src/lib_tests.rs b/codex-rs/file-search/src/lib_tests.rs index d2b78b4e57..ddb369072f 100644 --- a/codex-rs/file-search/src/lib_tests.rs +++ b/codex-rs/file-search/src/lib_tests.rs @@ -91,6 +91,42 @@ fn matching_filenames_survive_the_path_result_limit() { ); } +#[test] +fn overlapping_roots_share_result_slots_by_path() { + let dir = tempfile::tempdir().unwrap(); + let parent = dir.path().to_path_buf(); + let child = parent.join("sub"); + fs::create_dir(&child).unwrap(); + let expected = [parent.clone(), child.clone()].map(|root| (root, PathBuf::from("target.txt"))); + for (root, path) in &expected { + fs::write(root.join(path), "").unwrap(); + } + + for roots in [ + vec![parent.clone(), child.clone(), parent.clone()], + vec![child.clone(), parent, child], + ] { + let result = run( + "target", + roots, + FileSearchOptions { + limit: NonZero::new(2).unwrap(), + ..Default::default() + }, + /*cancel_flag*/ None, + ) + .unwrap(); + let mut paths: Vec<_> = result + .matches + .into_iter() + .map(|m| (m.root, m.path)) + .collect(); + paths.sort(); + assert_eq!(result.total_match_count, expected.len()); + assert_eq!(paths, expected); + } +} + #[test] fn file_name_from_path_uses_basename() { assert_eq!(file_name_from_path("foo/bar.txt"), "bar.txt");