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:
can1357
2026-07-11 06:04:21 +02:00
parent aa2c580b22
commit 5a4a6670b1
2 changed files with 102 additions and 11 deletions
+101 -11
View File
@@ -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");