search: Fix ignored file search in nested Git directories (#60772)

# Objective

- Fix ignored file search in nested Git directories (even just empty
`.git` directories, not real git repositories)
- Fixes #52328

## Solution

Keep parent ignore rules when rebuilding a nested repository's ignore
stack. This hides root-ignored files again after excluded-file search is
turned off, without breaking global ignore handling.

For example:

```ascii
project/
├── .git/
├── .gitignore    # log/
├── app/
│   └── a.txt     # hello
└── log/
    ├── .git/     # nested repository marker
    └── b.txt     # hello
```

Both `a.txt` and `b.txt` have string `hello`.
Initially, searching for `hello` returns only `a.txt`. Enabling `Also
search files ignored by configuration` rescans `log/` and returns both
files.
Before this change, disabling the option still
returned both files because the rescan dropped the parent `.gitignore`
and marked `b.txt` as included.

## Testing

- Did you test these changes? If so, how?

To test changes in this pull request create a simple project with the
following dir structure:

   ```
   .git
   .gitignore
   a/a.txt
   b/b.txt
   b/.git  # this is an empty directory
   ```

   Add `b` folder as an ignore entry to the `.gitignore` file: `b/`

- Are there any parts that need more testing?

   No

- How can other people (reviewers) test your changes? Is there anything
specific they need to know?

   Use the test scenario above

- If relevant, what platforms did you test these changes on, and are
there any important ones you can't test?

   macOS

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

## Showcase

By using the structure above here is how it works before changes here:



https://github.com/user-attachments/assets/2028123e-388e-4390-b131-ddf4642ab5f3

Notice that `hello` string from file `b.txt` is listed in search results
regardless of the option `Include excluded files`

And here is after:



https://github.com/user-attachments/assets/99c627a2-e16a-4fdd-979b-2e0bacbd827d



Notice that `hello` string from file `b.txt` is **not** listed in search
results when the option `Include excluded files` is disabled.

Release Notes:

- Fixed ignored file search in nested Git directories
This commit is contained in:
Vitaly Slobodin authored and GitHub committed 2026-07-12 22:26:11 +00:00
1 parent cc6d83e1d4
commit bfc278bee0
3 files changed
+183 -10

No files matched your search

+169
View File
@@ -2823,6 +2823,175 @@ pub mod tests {
assert_eq!(split_glob_patterns(r"a\\\,b"), vec![r"a\\\,b"]);
}
#[perf]
#[gpui::test]
async fn test_ignored_dot_git_directory_results_follow_include_ignored_option(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/dir"),
json!({
".gitignore": "log/\n",
"app": {
"a.txt": "hello",
},
"log": {
".git": {},
"b.txt": "hello",
},
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let search = cx.new(|cx| ProjectSearch::new(project, cx));
let search_view = cx.add_window(|window, cx| {
ProjectSearchView::new(workspace.downgrade(), search, window, cx, None)
});
perform_search(search_view, "hello", cx);
assert_eq!(
search_view
.update(cx, |search_view, _, cx| {
search_view.entity.read(cx).match_ranges.len()
})
.unwrap(),
1
);
search_view
.update(cx, |search_view, _, cx| {
search_view.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
})
.unwrap();
perform_search(search_view, "hello", cx);
assert_eq!(
search_view
.update(cx, |search_view, _, cx| {
search_view.entity.read(cx).match_ranges.len()
})
.unwrap(),
2
);
search_view
.update(cx, |search_view, _, cx| {
search_view.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
})
.unwrap();
perform_search(search_view, "hello", cx);
assert_eq!(
search_view
.update(cx, |search_view, _, cx| {
search_view.entity.read(cx).match_ranges.len()
})
.unwrap(),
1
);
}
#[perf]
#[gpui::test]
async fn test_unignored_dot_git_directory_results_are_included(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/dir"),
json!({
"app": {
"a.txt": "hello",
},
"log": {
".git": {},
"b.txt": "hello",
},
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let search = cx.new(|cx| ProjectSearch::new(project, cx));
let search_view = cx.add_window(|window, cx| {
ProjectSearchView::new(workspace.downgrade(), search, window, cx, None)
});
perform_search(search_view, "hello", cx);
assert_eq!(
search_view
.update(cx, |search_view, _, cx| {
search_view.entity.read(cx).match_ranges.len()
})
.unwrap(),
2
);
}
#[perf]
#[gpui::test]
async fn test_nested_gitignore_results_follow_include_ignored_option(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/dir"),
json!({
"app": {
"a.txt": "hello",
},
"log": {
".git": {},
".gitignore": "b.txt\n",
"b.txt": "hello",
},
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let search = cx.new(|cx| ProjectSearch::new(project, cx));
let search_view = cx.add_window(|window, cx| {
ProjectSearchView::new(workspace.downgrade(), search, window, cx, None)
});
perform_search(search_view, "hello", cx);
assert_eq!(
search_view
.update(cx, |search_view, _, cx| {
search_view.entity.read(cx).match_ranges.len()
})
.unwrap(),
1
);
search_view
.update(cx, |search_view, _, cx| {
search_view.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
})
.unwrap();
perform_search(search_view, "hello", cx);
assert_eq!(
search_view
.update(cx, |search_view, _, cx| {
search_view.entity.read(cx).match_ranges.len()
})
.unwrap(),
2
);
}
#[perf]
#[gpui::test]
async fn test_project_search(cx: &mut TestAppContext) {
+5 -5
View File
@@ -85,12 +85,12 @@ impl IgnoreStack {
IgnoreStackEntry::Global { ignore } => {
let combined_path;
let abs_path = if let Some(repo_root) = self.repo_root.as_ref() {
combined_path = ignore.path().join(
if let Ok(relative_path) = abs_path.strip_prefix(repo_root) {
combined_path = ignore.path().join(relative_path);
&combined_path
} else {
abs_path
.strip_prefix(repo_root)
.expect("repo root should be a parent of matched path"),
);
&combined_path
}
} else {
abs_path
};
+9 -5
View File
@@ -2970,10 +2970,11 @@ impl LocalSnapshot {
}
}
let metadata = fs.metadata(&ancestor.join(DOT_GIT)).await.ok().flatten();
if metadata.is_some() {
repo_root = Some(Arc::from(ancestor));
break;
if repo_root.is_none() {
let metadata = fs.metadata(&ancestor.join(DOT_GIT)).await.ok().flatten();
if metadata.is_some() {
repo_root = Some(Arc::from(ancestor));
}
}
}
@@ -2990,11 +2991,14 @@ impl LocalSnapshot {
ignore_stack = ignore_stack.append(IgnoreKind::RepoExclude, repo_exclude.clone());
}
ignore_stack.repo_root = repo_root;
let mut ancestor_ignore_stack = ignore_stack.clone();
for (parent_abs_path, ignore) in new_ignores.into_iter().rev() {
if ignore_stack.is_abs_path_ignored(parent_abs_path, true) {
if ancestor_ignore_stack.is_abs_path_ignored(parent_abs_path, true) {
ignore_stack = IgnoreStack::all();
break;
} else if let Some(ignore) = ignore {
let kind = IgnoreKind::Gitignore(parent_abs_path.into());
ancestor_ignore_stack = ancestor_ignore_stack.append(kind, ignore.clone());
ignore_stack =
ignore_stack.append(IgnoreKind::Gitignore(parent_abs_path.into()), ignore);
}