Files
zed/crates/worktree/src/ignore.rs
T
Vitaly Slobodin bfc278bee0 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
2026-07-12 22:26:11 +00:00

130 lines
3.9 KiB
Rust

use ignore::gitignore::Gitignore;
use std::{ffi::OsStr, path::Path, sync::Arc};
#[derive(Clone, Debug)]
pub struct IgnoreStack {
pub repo_root: Option<Arc<Path>>,
pub top: Arc<IgnoreStackEntry>,
}
#[derive(Debug)]
pub enum IgnoreStackEntry {
None,
Global {
ignore: Arc<Gitignore>,
},
RepoExclude {
ignore: Arc<Gitignore>,
parent: Arc<IgnoreStackEntry>,
},
Some {
abs_base_path: Arc<Path>,
ignore: Arc<Gitignore>,
parent: Arc<IgnoreStackEntry>,
},
All,
}
#[derive(Debug)]
pub enum IgnoreKind {
Gitignore(Arc<Path>),
RepoExclude,
}
impl IgnoreStack {
pub fn none() -> Self {
Self {
repo_root: None,
top: Arc::new(IgnoreStackEntry::None),
}
}
pub fn all() -> Self {
Self {
repo_root: None,
top: Arc::new(IgnoreStackEntry::All),
}
}
pub fn global(ignore: Arc<Gitignore>) -> Self {
Self {
repo_root: None,
top: Arc::new(IgnoreStackEntry::Global { ignore }),
}
}
pub fn append(self, kind: IgnoreKind, ignore: Arc<Gitignore>) -> Self {
let top = match self.top.as_ref() {
IgnoreStackEntry::All => self.top.clone(),
_ => Arc::new(match kind {
IgnoreKind::Gitignore(abs_base_path) => IgnoreStackEntry::Some {
abs_base_path,
ignore,
parent: self.top.clone(),
},
IgnoreKind::RepoExclude => IgnoreStackEntry::RepoExclude {
ignore,
parent: self.top.clone(),
},
}),
};
Self {
repo_root: self.repo_root,
top,
}
}
pub fn is_abs_path_ignored(&self, abs_path: &Path, is_dir: bool) -> bool {
if is_dir && abs_path.file_name() == Some(OsStr::new(".git")) {
return true;
}
match self.top.as_ref() {
IgnoreStackEntry::None => false,
IgnoreStackEntry::All => true,
IgnoreStackEntry::Global { ignore } => {
let combined_path;
let abs_path = if let Some(repo_root) = self.repo_root.as_ref() {
if let Ok(relative_path) = abs_path.strip_prefix(repo_root) {
combined_path = ignore.path().join(relative_path);
&combined_path
} else {
abs_path
}
} else {
abs_path
};
match ignore.matched(abs_path, is_dir) {
ignore::Match::None => false,
ignore::Match::Ignore(_) => true,
ignore::Match::Whitelist(_) => false,
}
}
IgnoreStackEntry::RepoExclude { ignore, parent } => {
match ignore.matched(abs_path, is_dir) {
ignore::Match::None => IgnoreStack {
repo_root: self.repo_root.clone(),
top: parent.clone(),
}
.is_abs_path_ignored(abs_path, is_dir),
ignore::Match::Ignore(_) => true,
ignore::Match::Whitelist(_) => false,
}
}
IgnoreStackEntry::Some {
abs_base_path,
ignore,
parent: prev,
} => match ignore.matched(abs_path.strip_prefix(abs_base_path).unwrap(), is_dir) {
ignore::Match::None => IgnoreStack {
repo_root: self.repo_root.clone(),
top: prev.clone(),
}
.is_abs_path_ignored(abs_path, is_dir),
ignore::Match::Ignore(_) => true,
ignore::Match::Whitelist(_) => false,
},
}
}
}