feat(pi-walker): ignored parent rules that cover explicitly rooted walks
- Updated gitignore loading to filter out ancestor ignore rules that match the current explicit walk root. - Retained unrelated parent ignore rules and processed local ignore files within the root normally. - Added a regression test to ensure ignored roots still respect nested ignore rules.
This commit is contained in:
+101
-11
@@ -20,7 +20,7 @@ use std::{
|
||||
ffi::OsStr,
|
||||
fmt,
|
||||
hash::{Hash, Hasher},
|
||||
io,
|
||||
io::{self, BufRead},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
@@ -3033,13 +3033,65 @@ fn has_repo_marker(dir: &Path) -> bool {
|
||||
dir.join(".git").exists() || dir.join(".jj").exists()
|
||||
}
|
||||
|
||||
fn load_gitignore(root: &Path, file: &Path) -> Option<ignore::gitignore::Gitignore> {
|
||||
fn ignore_line_covers_root(
|
||||
matcher_root: &Path,
|
||||
source: &Path,
|
||||
line: &str,
|
||||
explicit_root: &Path,
|
||||
) -> bool {
|
||||
let mut builder = ignore::gitignore::GitignoreBuilder::new(matcher_root);
|
||||
builder.add_line(Some(source.to_path_buf()), line).is_ok()
|
||||
&& builder.build().is_ok_and(|matcher| {
|
||||
matcher
|
||||
.matched_path_or_any_parents(explicit_root, true)
|
||||
.is_ignore()
|
||||
})
|
||||
}
|
||||
|
||||
/// Load an ignore source, removing ancestor rules that cover an explicit walk
|
||||
/// root.
|
||||
///
|
||||
/// Unrelated parent rules remain active, while ignore files discovered at or
|
||||
/// below the root are loaded without filtering.
|
||||
fn load_gitignore(
|
||||
matcher_root: &Path,
|
||||
file: &Path,
|
||||
explicit_root: Option<&Path>,
|
||||
) -> Option<ignore::gitignore::Gitignore> {
|
||||
if !file.is_file() {
|
||||
return None;
|
||||
}
|
||||
let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
|
||||
let mut builder = ignore::gitignore::GitignoreBuilder::new(matcher_root);
|
||||
let _ = builder.add(file);
|
||||
builder.build().ok().filter(|matcher| !matcher.is_empty())
|
||||
let matcher = builder.build().ok().filter(|matcher| !matcher.is_empty())?;
|
||||
let Some(explicit_root) = explicit_root else {
|
||||
return Some(matcher);
|
||||
};
|
||||
if !matcher
|
||||
.matched_path_or_any_parents(explicit_root, true)
|
||||
.is_ignore()
|
||||
{
|
||||
return Some(matcher);
|
||||
}
|
||||
|
||||
let handle = std::fs::File::open(file).ok()?;
|
||||
let mut filtered = ignore::gitignore::GitignoreBuilder::new(matcher_root);
|
||||
let source = Some(file.to_path_buf());
|
||||
for (index, line) in io::BufReader::new(handle).lines().enumerate() {
|
||||
let Ok(line) = line else {
|
||||
break;
|
||||
};
|
||||
let line = if index == 0 {
|
||||
line.trim_start_matches('\u{feff}')
|
||||
} else {
|
||||
line.as_str()
|
||||
};
|
||||
if ignore_line_covers_root(matcher_root, file, line, explicit_root) {
|
||||
continue;
|
||||
}
|
||||
let _ = filtered.add_line(source.clone(), line);
|
||||
}
|
||||
filtered.build().ok().filter(|matcher| !matcher.is_empty())
|
||||
}
|
||||
|
||||
impl IgnoreState {
|
||||
@@ -3048,10 +3100,26 @@ impl IgnoreState {
|
||||
let git_exclude = dir.join(".git/info/exclude");
|
||||
Self::new(
|
||||
parent,
|
||||
load_gitignore(dir, &dir.join(".ignore")),
|
||||
load_gitignore(dir, &dir.join(".gitignore")),
|
||||
load_gitignore(dir, &dir.join(".ignore"), None),
|
||||
load_gitignore(dir, &dir.join(".gitignore"), None),
|
||||
if has_git {
|
||||
load_gitignore(dir, &git_exclude)
|
||||
load_gitignore(dir, &git_exclude, None)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
has_git,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_parent(dir: &Path, parent: Option<Arc<Self>>, explicit_root: &Path) -> Arc<Self> {
|
||||
let has_git = has_repo_marker(dir);
|
||||
let git_exclude = dir.join(".git/info/exclude");
|
||||
Self::new(
|
||||
parent,
|
||||
load_gitignore(dir, &dir.join(".ignore"), Some(explicit_root)),
|
||||
load_gitignore(dir, &dir.join(".gitignore"), Some(explicit_root)),
|
||||
if has_git {
|
||||
load_gitignore(dir, &git_exclude, Some(explicit_root))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -3067,17 +3135,17 @@ impl IgnoreState {
|
||||
Self::new(
|
||||
Some(Arc::clone(parent)),
|
||||
if names.ignore_file {
|
||||
load_gitignore(dir, &dir.join(".ignore"))
|
||||
load_gitignore(dir, &dir.join(".ignore"), None)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if names.gitignore_file {
|
||||
load_gitignore(dir, &dir.join(".gitignore"))
|
||||
load_gitignore(dir, &dir.join(".gitignore"), None)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if names.git_dir {
|
||||
load_gitignore(dir, &git_exclude)
|
||||
load_gitignore(dir, &git_exclude, None)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -3127,7 +3195,7 @@ impl IgnoreState {
|
||||
let repo_start = repo_start?;
|
||||
let mut parent = None;
|
||||
for ancestor in ancestors[..=repo_start].iter().rev() {
|
||||
parent = Some(Self::build(ancestor, parent));
|
||||
parent = Some(Self::build_parent(ancestor, parent, root));
|
||||
}
|
||||
parent
|
||||
}
|
||||
@@ -4295,6 +4363,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_ignored_root_keeps_unrelated_parent_and_nested_ignore_rules() {
|
||||
let tree = temp_tree("explicit-ignored-root");
|
||||
fs::create_dir_all(tree.path().join(".git")).expect("repo marker should be created");
|
||||
fs::write(tree.path().join(".gitignore"), "*.log\nignored/**\n")
|
||||
.expect("repo gitignore should be written");
|
||||
let project = tree.path().join("ignored").join("package");
|
||||
let nested = project.join("nested");
|
||||
fs::create_dir_all(&nested).expect("ignored project tree should be created");
|
||||
fs::write(project.join("keep.ts"), "keep").expect("kept file should be written");
|
||||
fs::write(project.join("trace.log"), "trace").expect("parent-ignored file should be written");
|
||||
fs::write(nested.join(".gitignore"), "generated.ts\n")
|
||||
.expect("nested gitignore should be written");
|
||||
fs::write(nested.join("generated.ts"), "generated")
|
||||
.expect("nested ignored file should be written");
|
||||
fs::write(nested.join("keep.ts"), "nested keep").expect("nested kept file should be written");
|
||||
|
||||
let paths = collect_file_paths(&project, true);
|
||||
|
||||
assert_eq!(paths, vec!["keep.ts", "nested/.gitignore", "nested/keep.ts"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_request_files_only_returns_relative_files_and_excludes_directories() {
|
||||
let tree = temp_tree("request-files-only");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed non-recursive glob patterns traversing entire subtrees they could never match into: `dir/*.json` walked everything under `dir` (unbounded depth) because only the match filter — not the walk — knew the pattern was shallow. The walker is now depth-bounded by the pattern's segment count when it contains no `**` or brace alternation (wildcards never cross `/`), so narrow direct-child globs over huge directories (`~/.cache/*`-style) return in milliseconds instead of hitting the 5s timeout with zero partial matches.
|
||||
- Fixed native filesystem searches (`glob`, `grep`, and AST search/edit) treating an explicitly rooted ignored directory as still excluded by its ancestor rule. Walks now discard only ancestor ignore patterns that cover the requested root, while preserving unrelated parent rules and ignore files discovered inside the root.
|
||||
|
||||
## [16.3.13] - 2026-07-09
|
||||
|
||||
|
||||
Reference in New Issue
Block a user