use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use ignore::Match;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
pub const DOT_IGNORE_NAME: &str = ".ignore";
pub const GITIGNORE_NAME: &str = ".gitignore";
const GIT_DIR_NAME: &str = ".git";
pub const MAX_PATTERNS: usize = 4096;
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct IgnoreSpec {
pub gitignore: bool,
pub dot_ignore: bool,
pub exclude_git: bool,
pub patterns: Vec<String>,
}
impl IgnoreSpec {
pub fn is_empty(&self) -> bool {
!self.reads_ignore_files() && !self.exclude_git && self.patterns.is_empty()
}
pub fn reads_ignore_files(&self) -> bool {
self.gitignore || self.dot_ignore
}
pub fn file_names(&self) -> Vec<&'static str> {
let mut names = Vec::with_capacity(2);
if self.dot_ignore {
names.push(DOT_IGNORE_NAME);
}
if self.gitignore {
names.push(GITIGNORE_NAME);
}
names
}
pub fn parse_patterns(text: &str) -> Vec<String> {
let mut patterns: Vec<String> = text
.split('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line).trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect();
if !patterns.iter().any(|p| p.starts_with('!')) {
patterns.sort();
patterns.dedup();
}
patterns
}
}
pub struct Ignores {
root: PathBuf,
spec: IgnoreSpec,
exclude_git: bool,
file_names: Vec<&'static str>,
fold_case: bool,
overrides: Gitignore,
global: Option<Arc<Gitignore>>,
base: Vec<Arc<Gitignore>>,
info_excludes: std::collections::HashSet<PathBuf>,
external_sources: std::collections::HashSet<PathBuf>,
per_dir: HashMap<String, Option<Arc<Gitignore>>>,
stacks: HashMap<String, Arc<Vec<Arc<Gitignore>>>>,
dir_verdicts: HashMap<String, bool>,
}
const MAX_CACHED_DIRS: usize = 1 << 17;
impl Ignores {
pub fn new(root: &Path, spec: &IgnoreSpec) -> Ignores {
let file_names = spec.file_names();
let governing =
gitdir_at(root).or_else(|| enclosing_worktree_top(root).as_deref().and_then(gitdir_at));
let fold_case = governing.as_deref().is_some_and(config_ignorecase);
let mut overrides = GitignoreBuilder::new(root);
overrides.case_insensitive(fold_case).ok();
for pattern in &spec.patterns {
let _ = overrides.add_line(None, pattern);
}
let overrides = overrides.build().unwrap_or_else(|_| Gitignore::empty());
let mut global = None;
let mut base = Vec::new();
let mut info_excludes = std::collections::HashSet::new();
let mut external_sources = std::collections::HashSet::new();
if spec.reads_ignore_files() {
if spec.gitignore {
let (found, _) = Gitignore::global();
if !found.is_empty() {
let found = Arc::new(found);
base.push(found.clone());
global = Some(found);
}
}
match gitdir_at(root) {
Some(gitdir) => {
if spec.gitignore {
push_info_exclude(&mut base, &mut info_excludes, root, &gitdir, fold_case);
}
}
None => {
if let Some(top) = enclosing_worktree_top(root) {
if spec.gitignore
&& let Some(gitdir) = gitdir_at(&top)
{
let before = info_excludes.len();
push_info_exclude(
&mut base,
&mut info_excludes,
&top,
&gitdir,
fold_case,
);
if info_excludes.len() != before {
external_sources.extend(info_excludes.iter().cloned());
}
}
for dir in ancestors_between(&top, root) {
if let Some(matcher) = build_dir_matcher(&dir, &file_names, fold_case) {
base.push(matcher);
}
external_sources.extend(file_names.iter().map(|n| dir.join(n)));
}
}
}
}
}
Ignores {
root: root.to_path_buf(),
spec: spec.clone(),
exclude_git: spec.exclude_git,
file_names,
fold_case,
overrides,
global,
base,
info_excludes,
external_sources,
per_dir: HashMap::new(),
stacks: HashMap::new(),
dir_verdicts: HashMap::new(),
}
}
pub fn external_watch_dirs(&self) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = self
.external_sources
.iter()
.filter_map(|p| p.parent().map(Path::to_path_buf))
.collect();
dirs.sort();
dirs.dedup();
dirs
}
pub fn is_external_source(&self, abs: &Path) -> bool {
self.external_sources.contains(abs)
}
pub fn source_affects_rules(&mut self, abs: &Path, rel: &str) -> bool {
if !self.is_source_abs(abs) {
return false;
}
if self.is_info_exclude(abs) {
return match repo_top_of_info_exclude(rel) {
Some(top) => !self.matched(top, true),
None => true,
};
}
match crate::parent_wire(rel) {
Some(dir) => !self.matched(dir, true),
None => true,
}
}
pub fn is_source_abs(&self, abs: &Path) -> bool {
if self.file_names.is_empty() {
return false;
}
if abs
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| self.file_names.contains(&name))
{
return true;
}
self.is_info_exclude(abs)
}
fn is_info_exclude(&self, abs: &Path) -> bool {
abs.ends_with(Path::new(GIT_DIR_NAME).join("info").join("exclude"))
|| self.info_excludes.contains(abs)
}
pub fn invalidate(&mut self) {
*self = Ignores::new(&self.root.clone(), &self.spec.clone());
}
pub fn matched(&mut self, rel: &str, is_dir: bool) -> bool {
if rel.is_empty() {
return false;
}
if self.exclude_git && rel.split('/').any(|c| c == GIT_DIR_NAME) {
return true;
}
if self.overrides.is_empty() && self.base.is_empty() && self.file_names.is_empty() {
return false;
}
let parent = crate::parent_wire(rel).unwrap_or("");
if self.dir_excluded(parent) {
return true;
}
let Some(abs) = crate::resolve_wire_path(&self.root, rel) else {
return false;
};
self.decide(parent, &abs, is_dir)
}
fn dir_excluded(&mut self, dir: &str) -> bool {
if dir.is_empty() {
return false;
}
if let Some(known) = self.dir_verdicts.get(dir) {
return *known;
}
let parent = crate::parent_wire(dir).unwrap_or("");
let excluded = self.dir_excluded(parent)
|| crate::resolve_wire_path(&self.root, dir)
.is_some_and(|abs| self.decide(parent, &abs, true));
self.trim_caches();
self.dir_verdicts.insert(dir.to_string(), excluded);
excluded
}
fn trim_caches(&mut self) {
if self.dir_verdicts.len() < MAX_CACHED_DIRS
&& self.stacks.len() < MAX_CACHED_DIRS
&& self.per_dir.len() < MAX_CACHED_DIRS
{
return;
}
self.per_dir.clear();
self.stacks.clear();
self.dir_verdicts.clear();
}
fn decide(&mut self, parent_dir: &str, abs: &Path, is_dir: bool) -> bool {
match self.overrides.matched(abs, is_dir) {
Match::Ignore(_) => return true,
Match::Whitelist(_) => return false,
Match::None => {}
}
let stack = self.stack_for(parent_dir);
for matcher in stack.iter().rev() {
match matcher.matched(abs, is_dir) {
Match::Ignore(_) => return true,
Match::Whitelist(_) => return false,
Match::None => {}
}
}
false
}
fn stack_for(&mut self, dir: &str) -> Arc<Vec<Arc<Gitignore>>> {
if let Some(cached) = self.stacks.get(dir) {
return cached.clone();
}
let mut stack = match self.nested_repo_base(dir) {
Some(fresh) => fresh,
None => match crate::parent_wire(dir) {
Some(parent) => (*self.stack_for(parent)).clone(),
None => self.base.clone(),
},
};
if let Some(matcher) = self.dir_matcher(dir) {
stack.push(matcher);
}
let stack = Arc::new(stack);
self.stacks.insert(dir.to_string(), stack.clone());
stack
}
fn nested_repo_base(&mut self, dir: &str) -> Option<Vec<Arc<Gitignore>>> {
if self.file_names.is_empty() || dir.is_empty() {
return None;
}
let abs = crate::resolve_wire_path(&self.root, dir)?;
let gitdir = gitdir_at(&abs)?;
let mut base = Vec::new();
base.extend(self.global.clone());
push_info_exclude(
&mut base,
&mut self.info_excludes,
&abs,
&gitdir,
self.fold_case,
);
Some(base)
}
fn dir_matcher(&mut self, dir: &str) -> Option<Arc<Gitignore>> {
if self.file_names.is_empty() {
return None;
}
if let Some(cached) = self.per_dir.get(dir) {
return cached.clone();
}
let built = crate::resolve_wire_path(&self.root, dir)
.as_deref()
.and_then(|abs| build_dir_matcher(abs, &self.file_names, self.fold_case));
self.trim_caches();
self.per_dir.insert(dir.to_string(), built.clone());
built
}
}
fn build_dir_matcher(dir: &Path, names: &[&str], fold_case: bool) -> Option<Arc<Gitignore>> {
let paths: Vec<PathBuf> = names.iter().map(|n| dir.join(n)).collect();
build_dir_matcher_from(dir, &paths, fold_case)
}
fn build_dir_matcher_from(
dir: &Path,
files: &[PathBuf],
fold_case: bool,
) -> Option<Arc<Gitignore>> {
let mut builder = GitignoreBuilder::new(dir);
builder.case_insensitive(fold_case).ok();
let mut any = false;
for file in files {
if builder.add(file).is_none() {
any = true;
}
}
if !any {
return None;
}
let matcher = builder.build().ok()?;
(!matcher.is_empty()).then(|| Arc::new(matcher))
}
fn repo_top_of_info_exclude(rel: &str) -> Option<&str> {
let tail = format!("{GIT_DIR_NAME}/info/exclude");
if rel == tail {
return Some("");
}
rel.strip_suffix(&tail)?.strip_suffix('/')
}
fn push_info_exclude(
base: &mut Vec<Arc<Gitignore>>,
seen: &mut std::collections::HashSet<PathBuf>,
dir: &Path,
gitdir: &Path,
fold_case: bool,
) {
let exclude = gitdir.join("info").join("exclude");
if let Some(matcher) = build_dir_matcher_from(dir, std::slice::from_ref(&exclude), fold_case) {
base.push(matcher);
}
seen.insert(exclude);
}
fn config_ignorecase(gitdir: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(gitdir.join("config")) else {
return false;
};
let mut in_core = false;
for line in text.lines() {
let line = line.split('#').next().unwrap_or("").trim();
if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
in_core = section.trim().eq_ignore_ascii_case("core");
continue;
}
if !in_core {
continue;
}
let (key, value) = match line.split_once('=') {
Some((key, value)) => (key.trim(), value.trim()),
None => (line, "true"),
};
if key.eq_ignore_ascii_case("ignorecase") {
return !matches!(
value.to_ascii_lowercase().as_str(),
"false" | "no" | "off" | "0" | ""
);
}
}
false
}
fn gitdir_at(dir: &Path) -> Option<PathBuf> {
let candidate = dir.join(GIT_DIR_NAME);
match std::fs::metadata(&candidate) {
Ok(md) if md.is_dir() => Some(candidate),
Ok(md) if md.is_file() => {
let text = std::fs::read_to_string(&candidate).ok()?;
let target = Path::new(text.strip_prefix("gitdir:")?.trim());
Some(if target.is_absolute() {
target.to_path_buf()
} else {
dir.join(target)
})
}
_ => None,
}
}
fn enclosing_worktree_top(root: &Path) -> Option<PathBuf> {
std::iter::successors(root.parent(), |d| d.parent())
.find(|dir| gitdir_at(dir).is_some())
.map(Path::to_path_buf)
}
fn ancestors_between(top: &Path, root: &Path) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = std::iter::successors(root.parent(), |d| d.parent())
.take_while(|dir| dir.starts_with(top))
.map(Path::to_path_buf)
.collect();
dirs.reverse();
dirs
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(patterns: &[&str], ignore_files: bool, exclude_git: bool) -> IgnoreSpec {
IgnoreSpec {
gitignore: ignore_files,
dot_ignore: ignore_files,
exclude_git,
patterns: patterns.iter().map(|p| p.to_string()).collect(),
}
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"blit-fssync-ign-{}-{tag}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn patterns_normalize_and_drop_noise() {
let parsed = IgnoreSpec::parse_patterns("target\n\n # comment\n node_modules \r\n!keep");
assert_eq!(parsed, vec!["target", "node_modules", "!keep"]);
assert_eq!(
IgnoreSpec::parse_patterns("a\n\nb"),
IgnoreSpec::parse_patterns("a\nb")
);
}
#[test]
fn exclude_git_is_a_pure_name_filter() {
let dir = temp_dir("git");
let mut ign = Ignores::new(&dir, &spec(&[], false, true));
assert!(ign.matched(".git", true));
assert!(ign.matched(".git/config", false));
assert!(ign.matched("sub/.git", false), "a gitfile too");
assert!(!ign.matched(".gitignore", false));
assert!(!ign.matched("git", true));
assert!(!ign.matched("", true), "the root is never excluded");
}
#[test]
fn client_patterns_outrank_the_ignore_files() {
let dir = temp_dir("over");
std::fs::write(dir.join(".gitignore"), "build/\n").unwrap();
std::fs::create_dir_all(dir.join("build")).unwrap();
let mut ign = Ignores::new(&dir, &spec(&["!build/", "*.log"], true, false));
assert!(!ign.matched("build", true), "re-included by the client");
assert!(ign.matched("a.log", false));
}
#[test]
fn nested_ignore_files_stack_deepest_first() {
let dir = temp_dir("stack");
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join(".gitignore"), "*.tmp\n").unwrap();
std::fs::write(dir.join("sub/.gitignore"), "!keep.tmp\n").unwrap();
let mut ign = Ignores::new(&dir, &spec(&[], true, false));
assert!(ign.matched("a.tmp", false));
assert!(ign.matched("sub/other.tmp", false));
assert!(!ign.matched("sub/keep.tmp", false), "deeper file wins");
}
#[test]
fn an_excluded_directory_excludes_its_subtree() {
let dir = temp_dir("subtree");
std::fs::create_dir_all(dir.join("target/debug")).unwrap();
std::fs::write(dir.join(".gitignore"), "target/\n!target/debug/keep\n").unwrap();
let mut ign = Ignores::new(&dir, &spec(&[], true, false));
assert!(ign.matched("target", true));
assert!(ign.matched("target/debug", true));
assert!(ign.matched("target/debug/keep", false));
}
#[test]
fn a_directory_only_pattern_matches_directories_including_symlinked_ones() {
let dir = temp_dir("dironly");
let mut ign = Ignores::new(&dir, &spec(&["build/"], false, false));
assert!(ign.matched("build", true));
assert!(!ign.matched("build", false));
}
#[test]
fn invalidate_picks_up_an_edited_ignore_file() {
let dir = temp_dir("edit");
std::fs::write(dir.join(".gitignore"), "a.txt\n").unwrap();
let mut ign = Ignores::new(&dir, &spec(&[], true, false));
assert!(ign.matched("a.txt", false));
assert!(!ign.matched("b.txt", false));
std::fs::write(dir.join(".gitignore"), "b.txt\n").unwrap();
assert!(ign.matched("a.txt", false), "still the memoized stack");
ign.invalidate();
assert!(!ign.matched("a.txt", false));
assert!(ign.matched("b.txt", false));
let mut src = |rel: &str| ign.source_affects_rules(&dir.join(rel), rel);
assert!(src(".gitignore"));
assert!(src("sub/.ignore"));
assert!(!src("sub/notes.txt"));
}
#[test]
fn ignore_files_above_the_root_still_apply() {
let top = temp_dir("parent");
std::fs::create_dir_all(top.join(".git")).unwrap();
std::fs::create_dir_all(top.join("crates")).unwrap();
std::fs::write(top.join(".gitignore"), "*.bak\n").unwrap();
std::fs::write(top.join(".git/info-placeholder"), "").unwrap();
let root = top.join("crates");
let mut ign = Ignores::new(&root, &spec(&[], true, false));
assert!(
ign.matched("a.bak", false),
"inherited from the worktree top"
);
}
#[test]
fn an_enclosing_info_exclude_is_anchored_at_the_worktree_top() {
let top = temp_dir("infoexcl-anchor");
std::fs::create_dir_all(top.join(".git/info")).unwrap();
std::fs::create_dir_all(top.join("crates/build")).unwrap();
std::fs::create_dir_all(top.join("build")).unwrap();
std::fs::write(top.join(".git/info/exclude"), "/build\ncrates/gen\n").unwrap();
let mut whole = Ignores::new(&top, &spec(&[], true, false));
assert!(whole.matched("build", true));
assert!(!whole.matched("crates/build", true));
assert!(whole.matched("crates/gen", true));
let root = top.join("crates");
let mut sub = Ignores::new(&root, &spec(&[], true, false));
assert!(
!sub.matched("build", true),
"/build in the top's info/exclude is <top>/build, not <root>/build"
);
assert!(
sub.matched("gen", true),
"crates/gen resolves to this root's gen"
);
}
#[test]
fn info_exclude_is_honored_and_is_a_source() {
let dir = temp_dir("infoexcl");
std::fs::create_dir_all(dir.join(".git/info")).unwrap();
std::fs::write(dir.join(".git/info/exclude"), "secret*\n").unwrap();
let mut ign = Ignores::new(&dir, &spec(&[], true, true));
assert!(ign.matched("secret.txt", false));
assert!(ign.is_source_abs(&dir.join(".git/info/exclude")));
assert!(
ign.source_affects_rules(&dir.join(".git/info/exclude"), ".git/info/exclude"),
"the root's own info/exclude matters even though .git is excluded"
);
}
#[test]
fn a_nested_repository_starts_a_fresh_stack() {
let dir = temp_dir("nested");
std::fs::create_dir_all(dir.join(".git")).unwrap();
std::fs::write(dir.join(".gitignore"), "*.rs\n").unwrap();
std::fs::create_dir_all(dir.join("vendor/lib/.git/info")).unwrap();
std::fs::write(dir.join("vendor/lib/.gitignore"), "*.txt\n").unwrap();
std::fs::write(dir.join("vendor/lib/.git/info/exclude"), "local-*\n").unwrap();
let mut ign = Ignores::new(&dir, &spec(&[], true, false));
assert!(ign.matched("a.rs", false), "the outer rule, outside");
assert!(
!ign.matched("vendor/lib/a.rs", false),
"an outer repository's rules do not reach into a nested one"
);
assert!(ign.matched("vendor/lib/b.txt", false), "the inner rule");
assert!(
ign.matched("vendor/lib/local-notes.md", false),
"the nested repository's own info/exclude"
);
assert!(
ign.is_source_abs(&dir.join("vendor/lib/.git/info/exclude")),
"editing it has to invalidate the stack"
);
assert!(ign.matched("vendor/c.rs", false));
}
#[test]
fn a_root_that_is_a_repo_top_ignores_the_outer_repo() {
let outer = temp_dir("outertop");
std::fs::create_dir_all(outer.join(".git")).unwrap();
std::fs::write(outer.join(".gitignore"), "*.bak\n").unwrap();
let inner = outer.join("inner");
std::fs::create_dir_all(inner.join(".git")).unwrap();
let mut ign = Ignores::new(&inner, &spec(&[], true, false));
assert!(!ign.matched("a.bak", false));
let plain = outer.join("plain");
std::fs::create_dir_all(&plain).unwrap();
let mut ign = Ignores::new(&plain, &spec(&[], true, false));
assert!(ign.matched("a.bak", false));
}
#[test]
fn pattern_order_normalizes_only_when_it_carries_no_meaning() {
assert_eq!(
IgnoreSpec::parse_patterns("b\na\nb"),
IgnoreSpec::parse_patterns("a\nb"),
"reordered and duplicated exclusions are the same request"
);
assert_eq!(
IgnoreSpec::parse_patterns("*.log\n!keep.log"),
vec!["*.log", "!keep.log"],
"a negation pins the order — sorting it would invert the rule"
);
assert_ne!(
IgnoreSpec::parse_patterns("!keep.log\n*.log"),
IgnoreSpec::parse_patterns("*.log\n!keep.log"),
);
}
#[test]
fn the_two_ignore_file_kinds_are_independent() {
let dir = temp_dir("kinds");
std::fs::create_dir_all(dir.join(".git/info")).unwrap();
std::fs::write(dir.join(".git/info/exclude"), "excluded-*\n").unwrap();
std::fs::write(dir.join(".gitignore"), "from-git\n").unwrap();
std::fs::write(dir.join(".ignore"), "from-dot\n").unwrap();
let only_git = IgnoreSpec {
gitignore: true,
..Default::default()
};
let mut ign = Ignores::new(&dir, &only_git);
assert!(ign.matched("from-git", false));
assert!(!ign.matched("from-dot", false));
assert!(ign.matched("excluded-x", false), "info/exclude is git's");
let only_dot = IgnoreSpec {
dot_ignore: true,
..Default::default()
};
let mut ign = Ignores::new(&dir, &only_dot);
assert!(!ign.matched("from-git", false));
assert!(ign.matched("from-dot", false));
assert!(
!ign.matched("excluded-x", false),
"`.ignore` alone reads no git sources"
);
assert!(
!ign.source_affects_rules(&dir.join(".gitignore"), ".gitignore"),
"a file this spec never reads is not one of its sources"
);
}
#[test]
fn core_ignorecase_folds_the_matchers() {
let dir = temp_dir("icase");
std::fs::create_dir_all(dir.join(".git")).unwrap();
std::fs::write(dir.join(".gitignore"), "Build/\n*.LOG\n").unwrap();
std::fs::write(
dir.join(".git/config"),
"[core]\n\trepositoryformatversion = 0\n",
)
.unwrap();
let mut ign = Ignores::new(&dir, &spec(&["Vendor"], true, false));
assert!(!ign.matched("build", true), "case-sensitive by default");
assert!(!ign.matched("a.log", false));
assert!(!ign.matched("vendor", true));
std::fs::write(dir.join(".git/config"), "[core]\n\tignorecase = true\n").unwrap();
let mut ign = Ignores::new(&dir, &spec(&["Vendor"], true, false));
assert!(ign.matched("build", true));
assert!(ign.matched("a.log", false));
assert!(ign.matched("vendor", true), "client patterns fold too");
std::fs::write(dir.join(".git/config"), "[core]\nignorecase = false\n").unwrap();
assert!(!Ignores::new(&dir, &spec(&[], true, false)).matched("build", true));
std::fs::write(dir.join(".git/config"), "[core]\nignorecase\n").unwrap();
assert!(Ignores::new(&dir, &spec(&[], true, false)).matched("build", true));
std::fs::write(dir.join(".git/config"), "[other]\nignorecase = true\n").unwrap();
assert!(!Ignores::new(&dir, &spec(&[], true, false)).matched("build", true));
}
#[test]
fn nothing_configured_matches_nothing() {
let dir = temp_dir("empty");
assert!(IgnoreSpec::default().is_empty());
let mut ign = Ignores::new(&dir, &IgnoreSpec::default());
assert!(!ign.matched("anything/at/all", false));
assert!(!ign.source_affects_rules(&dir.join(".gitignore"), ".gitignore"));
}
#[test]
fn an_ignore_file_under_an_excluded_directory_changes_no_rules() {
let dir = temp_dir("deadsrc");
std::fs::create_dir_all(dir.join(".git/info")).unwrap();
std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join(".gitignore"), "node_modules/\n").unwrap();
let mut ign = Ignores::new(&dir, &spec(&[], true, true));
let affects = |ign: &mut Ignores, rel: &str| ign.source_affects_rules(&dir.join(rel), rel);
assert!(affects(&mut ign, ".gitignore"), "the root's own");
assert!(affects(&mut ign, "src/.gitignore"), "a live subdirectory");
assert!(
!affects(&mut ign, "node_modules/.gitignore"),
"inside an excluded directory: never read, so never a rebuild"
);
assert!(!affects(&mut ign, "node_modules/pkg/.gitignore"));
assert!(!affects(&mut ign, "src/notes.txt"), "not a source at all");
std::fs::create_dir_all(dir.join("vendor/lib/.git/info")).unwrap();
assert!(affects(&mut ign, "vendor/lib/.git/info/exclude"));
assert!(
!affects(&mut ign, "node_modules/pkg/.git/info/exclude"),
"a repository inside an excluded directory is not indexed either"
);
}
}