blit_fssync/ignores.rs
1//! Configurable exclusion for a synced root (docs/design/fs-watch.md
2//! "Ignoring").
3//!
4//! Three sources, composed into one matcher and evaluated per path:
5//!
6//! - **`.git`** — a pure name filter, no git data read.
7//! - **ignore files** — the enclosing worktree's exclude stack: every
8//! `.gitignore` / `.ignore` from the worktree top down to the deepest
9//! directory on the path, plus `$GIT_DIR/info/exclude` and the user's
10//! `core.excludesFile`.
11//! - **client patterns** — gitignore syntax, anchored at the sync root,
12//! highest precedence so `!keep-this` re-includes what the rest hide.
13//!
14//! Precedence is git's: the deepest ignore file wins over shallower ones,
15//! a match on an ancestor *directory* excludes everything below it (which
16//! is why a negation cannot resurrect a file under an excluded directory),
17//! and client patterns sit above the whole stack.
18//!
19//! Filtering is not a view over a full index — an excluded path is never
20//! stated, indexed, hashed, or counted against the entry budget, and its
21//! hints are dropped before the settle tick. That is the whole point: a
22//! sync of a checkout should cost the checkout, not `node_modules`.
23
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27
28use ignore::Match;
29use ignore::gitignore::{Gitignore, GitignoreBuilder};
30
31/// The non-git per-directory ignore file — ripgrep's convention, honored
32/// by the `FS_INDEX` / `FS_GREP` walkers and selectable here on its own.
33pub const DOT_IGNORE_NAME: &str = ".ignore";
34/// The git per-directory ignore file. Lower in the same directory than
35/// [`DOT_IGNORE_NAME`] is: within one directory `.gitignore` wins, matching
36/// the walkers.
37pub const GITIGNORE_NAME: &str = ".gitignore";
38
39/// Directory name excluded by `exclude_git`, and the gitdir marker the
40/// worktree search looks for.
41const GIT_DIR_NAME: &str = ".git";
42
43/// Cap on client patterns, so a hostile `FS_SYNC` cannot compile an
44/// unbounded glob set. Refused at request validation, not silently cut.
45pub const MAX_PATTERNS: usize = 4096;
46
47/// What a sync excludes. Part of the shared root's identity ([`crate::RootKey`]):
48/// two syncs indexing different trees cannot share a reconciler, exactly as
49/// for `recursive` — so this derives `Hash`/`Eq` over the normalized
50/// pattern list rather than over a compiled matcher.
51#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
52pub struct IgnoreSpec {
53 /// Honor `.gitignore` in and above the root, plus the governing
54 /// repository's `$GIT_DIR/info/exclude` and the user's
55 /// `core.excludesFile`.
56 pub gitignore: bool,
57 /// Honor `.ignore` in and above the root — ripgrep's convention,
58 /// which a project can use to hide things from tooling without
59 /// telling git to stop tracking them.
60 pub dot_ignore: bool,
61 /// Omit every entry whose final component is exactly `.git`.
62 pub exclude_git: bool,
63 /// Extra gitignore-syntax patterns, root-anchored, highest precedence.
64 pub patterns: Vec<String>,
65}
66
67impl IgnoreSpec {
68 /// Nothing to exclude: the reconciler skips building a matcher at all,
69 /// so an unfiltered sync pays exactly what it paid before.
70 pub fn is_empty(&self) -> bool {
71 !self.reads_ignore_files() && !self.exclude_git && self.patterns.is_empty()
72 }
73
74 /// Whether any per-directory ignore file is consulted.
75 pub fn reads_ignore_files(&self) -> bool {
76 self.gitignore || self.dot_ignore
77 }
78
79 /// Per-directory ignore file names this spec reads, ascending
80 /// precedence within one directory.
81 pub fn file_names(&self) -> Vec<&'static str> {
82 let mut names = Vec::with_capacity(2);
83 if self.dot_ignore {
84 names.push(DOT_IGNORE_NAME);
85 }
86 if self.gitignore {
87 names.push(GITIGNORE_NAME);
88 }
89 names
90 }
91
92 /// Normalize the wire form — one gitignore line per `\n` — into the
93 /// pattern list. Blank lines and `#` comments are dropped here rather
94 /// than at compile time so that two specs differing only in whitespace
95 /// share one shared root.
96 ///
97 /// Order carries meaning only when a negation is present, since
98 /// gitignore's rule is last-match-wins and a list of pure exclusions
99 /// commutes. Such a list is therefore sorted and deduplicated, so two
100 /// clients that asked for the same thing in a different order share
101 /// one root instead of building two identical indexes. A list with a
102 /// `!` in it is left exactly as written — reordering it would change
103 /// what it means.
104 pub fn parse_patterns(text: &str) -> Vec<String> {
105 let mut patterns: Vec<String> = text
106 .split('\n')
107 .map(|line| line.strip_suffix('\r').unwrap_or(line).trim())
108 .filter(|line| !line.is_empty() && !line.starts_with('#'))
109 .map(str::to_string)
110 .collect();
111 if !patterns.iter().any(|p| p.starts_with('!')) {
112 patterns.sort();
113 patterns.dedup();
114 }
115 patterns
116 }
117}
118
119/// Compiled exclusion for one root. Per-directory matchers are built on
120/// first use and memoized, so the initial scan reads each `.gitignore`
121/// once and incremental reconciliation reads none.
122pub struct Ignores {
123 root: PathBuf,
124 /// Kept so [`Ignores::invalidate`] can rebuild from scratch. The
125 /// memo tables are not the only thing a rules change invalidates —
126 /// `base`, `global` and `fold_case` are all read once at construction
127 /// — so dropping the caches alone would keep serving an edited
128 /// ancestor `.gitignore` from the copy compiled at open.
129 spec: IgnoreSpec,
130 exclude_git: bool,
131 /// Per-directory file names to read, ascending precedence. Empty when
132 /// only `.git` and client patterns are configured.
133 file_names: Vec<&'static str>,
134 /// `core.ignorecase` from the governing repository: on a
135 /// case-insensitive filesystem git folds case when matching, and a
136 /// mirror that did not would exclude a different set of paths than
137 /// the repository it is mirroring.
138 fold_case: bool,
139 /// Client patterns: consulted before everything else, at every level.
140 /// They are the sync's own filter, not a repository's, so unlike
141 /// everything below they apply across repository boundaries too.
142 overrides: Gitignore,
143 /// The user's `core.excludesFile`, which every repository inherits —
144 /// kept apart from `base` so a nested repository can start a fresh
145 /// stack with it still at the bottom.
146 global: Option<Arc<Gitignore>>,
147 /// Sources for the root's own repository, ascending: `global`,
148 /// `$GIT_DIR/info/exclude`, then each `.gitignore` between the
149 /// enclosing worktree top and the root. Below every per-directory
150 /// matcher inside the root, and discarded at a nested repository.
151 base: Vec<Arc<Gitignore>>,
152 /// Every `$GIT_DIR/info/exclude` loaded so far — the root's, plus one
153 /// per nested repository discovered while scanning. Tracked because
154 /// they are the ignore sources whose names are neither of the
155 /// per-directory ones, so a change to one has to be recognized.
156 info_excludes: std::collections::HashSet<PathBuf>,
157 /// Ignore files outside the root that the stack consulted: the
158 /// ancestors' own, and the governing `info/exclude` when its gitdir
159 /// sits above the root. Watched separately, since no hint from inside
160 /// the tree could ever report them.
161 external_sources: std::collections::HashSet<PathBuf>,
162 /// Ignore files at one directory, keyed by its wire path (`""` = root).
163 /// `None` = that directory has no ignore file.
164 per_dir: HashMap<String, Option<Arc<Gitignore>>>,
165 /// `base` plus every per-directory matcher from the root down to this
166 /// directory, in ascending precedence. Memoized per directory so a
167 /// scan pays one lookup per level instead of re-walking the chain.
168 stacks: HashMap<String, Arc<Vec<Arc<Gitignore>>>>,
169 /// Whether each directory is excluded, itself or through an ancestor.
170 /// This is what keeps matching linear: without it every path re-tests
171 /// each of its ancestors against that ancestor's own stack, so a path
172 /// `d` deep costs O(d²) glob probes and a tree pays it per entry.
173 dir_verdicts: HashMap<String, bool>,
174}
175
176/// Cache ceiling, in directories, across the three memo tables. They are
177/// pure memoization of what the filesystem says, so the cheap bound is to
178/// drop them wholesale and pay the re-reads; the normal case never gets
179/// near it, since the tables hold one entry per *indexed* directory and
180/// the entry budget already bounds those.
181const MAX_CACHED_DIRS: usize = 1 << 17;
182
183impl Ignores {
184 /// Compile `spec` for `root`. Unparseable client patterns are dropped
185 /// individually: a sync is never refused for one bad glob, and the
186 /// server validates the list before it gets here.
187 pub fn new(root: &Path, spec: &IgnoreSpec) -> Ignores {
188 let file_names = spec.file_names();
189 let governing =
190 gitdir_at(root).or_else(|| enclosing_worktree_top(root).as_deref().and_then(gitdir_at));
191 let fold_case = governing.as_deref().is_some_and(config_ignorecase);
192 let mut overrides = GitignoreBuilder::new(root);
193 overrides.case_insensitive(fold_case).ok();
194 for pattern in &spec.patterns {
195 let _ = overrides.add_line(None, pattern);
196 }
197 let overrides = overrides.build().unwrap_or_else(|_| Gitignore::empty());
198 let mut global = None;
199 let mut base = Vec::new();
200 let mut info_excludes = std::collections::HashSet::new();
201 let mut external_sources = std::collections::HashSet::new();
202 if spec.reads_ignore_files() {
203 // `.gitignore`-only sources: the user's `core.excludesFile`
204 // and the repository's `info/exclude` are git's, so `.ignore`
205 // alone reads neither.
206 if spec.gitignore {
207 let (found, _) = Gitignore::global();
208 if !found.is_empty() {
209 let found = Arc::new(found);
210 base.push(found.clone());
211 global = Some(found);
212 }
213 }
214 match gitdir_at(root) {
215 // The root is itself a repository top: its own stack is
216 // the whole stack. An enclosing repository's rules do not
217 // reach inside it, exactly as they do not for a nested one.
218 Some(gitdir) => {
219 if spec.gitignore {
220 push_info_exclude(&mut base, &mut info_excludes, root, &gitdir, fold_case);
221 }
222 }
223 // Inside a worktree: the ignore files above the root still
224 // apply — a sync of `repo/crates` inherits `repo/.gitignore`
225 // — shallowest first, so a deeper file overrides.
226 None => {
227 if let Some(top) = enclosing_worktree_top(root) {
228 if spec.gitignore
229 && let Some(gitdir) = gitdir_at(&top)
230 {
231 let before = info_excludes.len();
232 // Anchored at the *worktree top*, not at the sync
233 // root: git reads `info/exclude` relative to the
234 // top, so `/build` there means `<top>/build` and
235 // nothing else. Anchoring it at `repo/crates`
236 // instead made it hide `repo/crates/build` — a
237 // path git would have synced — and miss the one it
238 // names. Non-anchored patterns (`target/`) match
239 // at every level either way, which is why this
240 // only shows up with a leading or embedded slash.
241 push_info_exclude(
242 &mut base,
243 &mut info_excludes,
244 &top,
245 &gitdir,
246 fold_case,
247 );
248 if info_excludes.len() != before {
249 external_sources.extend(info_excludes.iter().cloned());
250 }
251 }
252 for dir in ancestors_between(&top, root) {
253 if let Some(matcher) = build_dir_matcher(&dir, &file_names, fold_case) {
254 base.push(matcher);
255 }
256 // Recorded whether or not they exist today:
257 // creating one is exactly the change that has
258 // to invalidate the stack.
259 external_sources.extend(file_names.iter().map(|n| dir.join(n)));
260 }
261 }
262 }
263 }
264 }
265 Ignores {
266 root: root.to_path_buf(),
267 spec: spec.clone(),
268 exclude_git: spec.exclude_git,
269 file_names,
270 fold_case,
271 overrides,
272 global,
273 base,
274 info_excludes,
275 external_sources,
276 per_dir: HashMap::new(),
277 stacks: HashMap::new(),
278 dir_verdicts: HashMap::new(),
279 }
280 }
281
282 /// Directories outside the root that hold ignore sources this matcher
283 /// consulted, so the reconciler can watch them. Their *parents* are
284 /// what gets armed — a watch on a file follows its inode and misses
285 /// the rename-over an editor or `git config` performs, the same reason
286 /// a single-file sync watches the parent directory.
287 pub fn external_watch_dirs(&self) -> Vec<PathBuf> {
288 let mut dirs: Vec<PathBuf> = self
289 .external_sources
290 .iter()
291 .filter_map(|p| p.parent().map(Path::to_path_buf))
292 .collect();
293 dirs.sort();
294 dirs.dedup();
295 dirs
296 }
297
298 /// True when `abs` is one of the ignore sources above the root. Their
299 /// hints arrive from outside the tree, so nothing else recognizes them.
300 pub fn is_external_source(&self, abs: &Path) -> bool {
301 self.external_sources.contains(abs)
302 }
303
304 /// Whether a write to this ignore source can change what the matcher
305 /// excludes — the question the reconciler actually has, since the
306 /// answer costs it a full re-enumeration.
307 ///
308 /// An ignore file inside an already-excluded directory cannot: it is
309 /// never read, because the directory is never descended. That
310 /// distinction is not academic — `npm install` writes thousands of
311 /// `.gitignore` files under `node_modules`, and rescanning the root
312 /// for each one would make the exclusion cost more than it saves.
313 ///
314 /// `abs` is the absolute path and `rel` its wire path under the root.
315 pub fn source_affects_rules(&mut self, abs: &Path, rel: &str) -> bool {
316 if !self.is_source_abs(abs) {
317 return false;
318 }
319 if self.is_info_exclude(abs) {
320 // It governs the worktree three components above it, and is
321 // relevant exactly while that worktree is. Testing its own
322 // directory instead would always say no: `.git/info` is
323 // excluded by `EXCLUDE_GIT`, including for the root's own
324 // repository, which is the one case that always matters.
325 return match repo_top_of_info_exclude(rel) {
326 Some(top) => !self.matched(top, true),
327 // A gitfile-linked gitdir (a submodule's lives under the
328 // superproject's `.git/modules/`): no worktree to derive,
329 // and it is only in the recorded set because we loaded it.
330 None => true,
331 };
332 }
333 match crate::parent_wire(rel) {
334 Some(dir) => !self.matched(dir, true),
335 None => true,
336 }
337 }
338
339 /// True when an absolute path is an ignore source at all — before
340 /// asking whether it is a *relevant* one
341 /// ([`Ignores::source_affects_rules`]).
342 pub fn is_source_abs(&self, abs: &Path) -> bool {
343 if self.file_names.is_empty() {
344 return false;
345 }
346 if abs
347 .file_name()
348 .and_then(|n| n.to_str())
349 .is_some_and(|name| self.file_names.contains(&name))
350 {
351 return true;
352 }
353 self.is_info_exclude(abs)
354 }
355
356 /// `$GIT_DIR/info/exclude`, the one ignore source not named by a
357 /// per-directory file. Matched structurally rather than only
358 /// against what has been loaded, so a nested repository whose subtree
359 /// no scan has reached yet still gets its rules noticed; the recorded
360 /// set then covers gitfile-linked gitdirs (a submodule's lives under
361 /// the superproject's `.git/modules/`, not at `…/.git/info/exclude`).
362 fn is_info_exclude(&self, abs: &Path) -> bool {
363 abs.ends_with(Path::new(GIT_DIR_NAME).join("info").join("exclude"))
364 || self.info_excludes.contains(abs)
365 }
366
367 /// Recompile from disk. Called when an ignore source changed; the
368 /// reconciler pairs it with a full rescan, since entries the old rules
369 /// admitted may now be excluded and vice versa.
370 ///
371 /// A full rebuild rather than a cache drop: the sources read once at
372 /// construction — the ancestors' files, `info/exclude`,
373 /// `core.excludesFile`, `core.ignorecase` — are exactly the ones no
374 /// per-directory cache holds, so clearing only the caches would keep
375 /// serving an edited parent `.gitignore` from the copy compiled at
376 /// open.
377 pub fn invalidate(&mut self) {
378 *self = Ignores::new(&self.root.clone(), &self.spec.clone());
379 }
380
381 /// Whether the entry at wire path `rel` is excluded. `is_dir` means
382 /// the entry is *enumerated as* a directory — a symlink to one counts,
383 /// unlike in git, because this sync descends it: a `build/` pattern
384 /// that could not exclude a symlinked `build` would leave the one hole
385 /// through which a whole subtree still gets mirrored.
386 ///
387 /// The root itself is never excluded — a sync of an ignored directory
388 /// mirrors it, which is what the client asked for.
389 pub fn matched(&mut self, rel: &str, is_dir: bool) -> bool {
390 if rel.is_empty() {
391 return false;
392 }
393 if self.exclude_git && rel.split('/').any(|c| c == GIT_DIR_NAME) {
394 return true;
395 }
396 if self.overrides.is_empty() && self.base.is_empty() && self.file_names.is_empty() {
397 return false;
398 }
399 // An excluded directory excludes everything below it (git's rule,
400 // which is also why no deeper negation can undo it), and that
401 // verdict is memoized — so this is one map lookup per ancestor and
402 // one glob probe for the entry itself.
403 let parent = crate::parent_wire(rel).unwrap_or("");
404 if self.dir_excluded(parent) {
405 return true;
406 }
407 let Some(abs) = crate::resolve_wire_path(&self.root, rel) else {
408 return false;
409 };
410 self.decide(parent, &abs, is_dir)
411 }
412
413 /// Whether a directory is excluded, itself or through an ancestor,
414 /// memoized. Recursion terminates at the root, which never is.
415 fn dir_excluded(&mut self, dir: &str) -> bool {
416 if dir.is_empty() {
417 return false;
418 }
419 if let Some(known) = self.dir_verdicts.get(dir) {
420 return *known;
421 }
422 let parent = crate::parent_wire(dir).unwrap_or("");
423 let excluded = self.dir_excluded(parent)
424 || crate::resolve_wire_path(&self.root, dir)
425 .is_some_and(|abs| self.decide(parent, &abs, true));
426 self.trim_caches();
427 self.dir_verdicts.insert(dir.to_string(), excluded);
428 excluded
429 }
430
431 /// Keep the memo tables bounded. They only ever cache what the
432 /// filesystem says, so dropping them is always safe and costs re-reads
433 /// — and dropping all three together keeps them consistent with each
434 /// other.
435 fn trim_caches(&mut self) {
436 if self.dir_verdicts.len() < MAX_CACHED_DIRS
437 && self.stacks.len() < MAX_CACHED_DIRS
438 && self.per_dir.len() < MAX_CACHED_DIRS
439 {
440 return;
441 }
442 self.per_dir.clear();
443 self.stacks.clear();
444 self.dir_verdicts.clear();
445 }
446
447 /// One level's verdict: client patterns first, then the directory
448 /// stack from deepest to shallowest. A whitelist stops the search for
449 /// *this* component without whitelisting its children.
450 fn decide(&mut self, parent_dir: &str, abs: &Path, is_dir: bool) -> bool {
451 match self.overrides.matched(abs, is_dir) {
452 Match::Ignore(_) => return true,
453 Match::Whitelist(_) => return false,
454 Match::None => {}
455 }
456 let stack = self.stack_for(parent_dir);
457 for matcher in stack.iter().rev() {
458 match matcher.matched(abs, is_dir) {
459 Match::Ignore(_) => return true,
460 Match::Whitelist(_) => return false,
461 Match::None => {}
462 }
463 }
464 false
465 }
466
467 /// Matchers applying inside `dir` (wire path, `""` = root), ascending
468 /// precedence: the enclosing repository's `base`, then every
469 /// per-directory ignore file from that repository's top down to `dir`
470 /// inclusive.
471 fn stack_for(&mut self, dir: &str) -> Arc<Vec<Arc<Gitignore>>> {
472 if let Some(cached) = self.stacks.get(dir) {
473 return cached.clone();
474 }
475 let mut stack = match self.nested_repo_base(dir) {
476 // A repository nested inside the root is its own scope: git
477 // does not apply an outer repository's rules inside an inner
478 // one, so the stack restarts here rather than inheriting.
479 Some(fresh) => fresh,
480 // Recurse on the parent so a deep first touch memoizes the
481 // whole chain rather than rebuilding it per level.
482 None => match crate::parent_wire(dir) {
483 Some(parent) => (*self.stack_for(parent)).clone(),
484 None => self.base.clone(),
485 },
486 };
487 if let Some(matcher) = self.dir_matcher(dir) {
488 stack.push(matcher);
489 }
490 let stack = Arc::new(stack);
491 self.stacks.insert(dir.to_string(), stack.clone());
492 stack
493 }
494
495 /// A fresh stack when `dir` is the top of a repository nested inside
496 /// the sync root: `core.excludesFile` (which every repository
497 /// inherits) plus that repository's own `info/exclude`, and nothing
498 /// from the outer one. `None` for an ordinary directory.
499 fn nested_repo_base(&mut self, dir: &str) -> Option<Vec<Arc<Gitignore>>> {
500 if self.file_names.is_empty() || dir.is_empty() {
501 return None;
502 }
503 let abs = crate::resolve_wire_path(&self.root, dir)?;
504 let gitdir = gitdir_at(&abs)?;
505 let mut base = Vec::new();
506 base.extend(self.global.clone());
507 push_info_exclude(
508 &mut base,
509 &mut self.info_excludes,
510 &abs,
511 &gitdir,
512 self.fold_case,
513 );
514 Some(base)
515 }
516
517 /// The ignore file(s) at one directory, compiled once.
518 fn dir_matcher(&mut self, dir: &str) -> Option<Arc<Gitignore>> {
519 if self.file_names.is_empty() {
520 return None;
521 }
522 if let Some(cached) = self.per_dir.get(dir) {
523 return cached.clone();
524 }
525 let built = crate::resolve_wire_path(&self.root, dir)
526 .as_deref()
527 .and_then(|abs| build_dir_matcher(abs, &self.file_names, self.fold_case));
528 self.trim_caches();
529 self.per_dir.insert(dir.to_string(), built.clone());
530 built
531 }
532}
533
534/// Compile the ignore files present in `dir`, `None` when it has none.
535fn build_dir_matcher(dir: &Path, names: &[&str], fold_case: bool) -> Option<Arc<Gitignore>> {
536 let paths: Vec<PathBuf> = names.iter().map(|n| dir.join(n)).collect();
537 build_dir_matcher_from(dir, &paths, fold_case)
538}
539
540/// Compile `files` (any that exist) as ignore sources anchored at `dir`.
541fn build_dir_matcher_from(
542 dir: &Path,
543 files: &[PathBuf],
544 fold_case: bool,
545) -> Option<Arc<Gitignore>> {
546 let mut builder = GitignoreBuilder::new(dir);
547 builder.case_insensitive(fold_case).ok();
548 let mut any = false;
549 for file in files {
550 if builder.add(file).is_none() {
551 any = true;
552 }
553 }
554 if !any {
555 return None;
556 }
557 let matcher = builder.build().ok()?;
558 (!matcher.is_empty()).then(|| Arc::new(matcher))
559}
560
561/// The wire path of the worktree an `info/exclude` governs: strip the
562/// `.git/info/exclude` tail. `None` when `rel` is not of that shape — a
563/// gitfile-linked gitdir, whose worktree is elsewhere entirely.
564fn repo_top_of_info_exclude(rel: &str) -> Option<&str> {
565 let tail = format!("{GIT_DIR_NAME}/info/exclude");
566 if rel == tail {
567 return Some("");
568 }
569 rel.strip_suffix(&tail)?.strip_suffix('/')
570}
571
572/// Add `gitdir`'s `info/exclude` to `base` (anchored at the worktree `dir`
573/// it governs) and record it as an ignore source.
574fn push_info_exclude(
575 base: &mut Vec<Arc<Gitignore>>,
576 seen: &mut std::collections::HashSet<PathBuf>,
577 dir: &Path,
578 gitdir: &Path,
579 fold_case: bool,
580) {
581 let exclude = gitdir.join("info").join("exclude");
582 if let Some(matcher) = build_dir_matcher_from(dir, std::slice::from_ref(&exclude), fold_case) {
583 base.push(matcher);
584 }
585 // Recorded whether or not it exists today: creating one later is the
586 // change that has to invalidate the stack.
587 seen.insert(exclude);
588}
589
590/// `core.ignorecase` from a repository's config.
591///
592/// git sets it at `init`/`clone` on a case-insensitive filesystem and then
593/// folds case when matching ignore rules, so a mirror that did not would
594/// exclude a different set of paths than the repository it mirrors. Read
595/// with a minimal INI scan rather than a git library: this crate needs one
596/// boolean, and pulling in a repository object to get it would put git on
597/// the path of every filtered sync — including the ones nowhere near a
598/// repository.
599fn config_ignorecase(gitdir: &Path) -> bool {
600 let Ok(text) = std::fs::read_to_string(gitdir.join("config")) else {
601 return false;
602 };
603 let mut in_core = false;
604 for line in text.lines() {
605 let line = line.split('#').next().unwrap_or("").trim();
606 if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
607 // `[core]` only — a subsection (`[core "x"]`) is a different
608 // section and carries no `ignorecase`.
609 in_core = section.trim().eq_ignore_ascii_case("core");
610 continue;
611 }
612 if !in_core {
613 continue;
614 }
615 // git's boolean spelling: a valueless key is true, as is anything
616 // but the explicit falses.
617 let (key, value) = match line.split_once('=') {
618 Some((key, value)) => (key.trim(), value.trim()),
619 None => (line, "true"),
620 };
621 if key.eq_ignore_ascii_case("ignorecase") {
622 return !matches!(
623 value.to_ascii_lowercase().as_str(),
624 "false" | "no" | "off" | "0" | ""
625 );
626 }
627 }
628 false
629}
630
631/// `dir`'s own gitdir if `dir` is a repository top, following a `.git`
632/// *file* (submodule / linked worktree) to the directory it names.
633fn gitdir_at(dir: &Path) -> Option<PathBuf> {
634 let candidate = dir.join(GIT_DIR_NAME);
635 match std::fs::metadata(&candidate) {
636 Ok(md) if md.is_dir() => Some(candidate),
637 Ok(md) if md.is_file() => {
638 let text = std::fs::read_to_string(&candidate).ok()?;
639 let target = Path::new(text.strip_prefix("gitdir:")?.trim());
640 Some(if target.is_absolute() {
641 target.to_path_buf()
642 } else {
643 dir.join(target)
644 })
645 }
646 _ => None,
647 }
648}
649
650/// The top of the worktree enclosing `root`, searched strictly above it.
651/// `None` when `root` is not inside one — a sync outside a repository
652/// reads no ignore files above itself.
653fn enclosing_worktree_top(root: &Path) -> Option<PathBuf> {
654 std::iter::successors(root.parent(), |d| d.parent())
655 .find(|dir| gitdir_at(dir).is_some())
656 .map(Path::to_path_buf)
657}
658
659/// Directories from `top` down to `root`'s parent, shallowest first —
660/// `root`'s own ignore files are the per-directory stack's first level, so
661/// they are not included here.
662fn ancestors_between(top: &Path, root: &Path) -> Vec<PathBuf> {
663 let mut dirs: Vec<PathBuf> = std::iter::successors(root.parent(), |d| d.parent())
664 .take_while(|dir| dir.starts_with(top))
665 .map(Path::to_path_buf)
666 .collect();
667 dirs.reverse();
668 dirs
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674
675 fn spec(patterns: &[&str], ignore_files: bool, exclude_git: bool) -> IgnoreSpec {
676 IgnoreSpec {
677 gitignore: ignore_files,
678 dot_ignore: ignore_files,
679 exclude_git,
680 patterns: patterns.iter().map(|p| p.to_string()).collect(),
681 }
682 }
683
684 fn temp_dir(tag: &str) -> PathBuf {
685 let dir = std::env::temp_dir().join(format!(
686 "blit-fssync-ign-{}-{tag}-{:?}",
687 std::process::id(),
688 std::thread::current().id()
689 ));
690 let _ = std::fs::remove_dir_all(&dir);
691 std::fs::create_dir_all(&dir).unwrap();
692 dir
693 }
694
695 #[test]
696 fn patterns_normalize_and_drop_noise() {
697 let parsed = IgnoreSpec::parse_patterns("target\n\n # comment\n node_modules \r\n!keep");
698 assert_eq!(parsed, vec!["target", "node_modules", "!keep"]);
699 // Two specs differing only in blank lines share a root.
700 assert_eq!(
701 IgnoreSpec::parse_patterns("a\n\nb"),
702 IgnoreSpec::parse_patterns("a\nb")
703 );
704 }
705
706 #[test]
707 fn exclude_git_is_a_pure_name_filter() {
708 let dir = temp_dir("git");
709 let mut ign = Ignores::new(&dir, &spec(&[], false, true));
710 assert!(ign.matched(".git", true));
711 assert!(ign.matched(".git/config", false));
712 assert!(ign.matched("sub/.git", false), "a gitfile too");
713 assert!(!ign.matched(".gitignore", false));
714 assert!(!ign.matched("git", true));
715 assert!(!ign.matched("", true), "the root is never excluded");
716 }
717
718 #[test]
719 fn client_patterns_outrank_the_ignore_files() {
720 let dir = temp_dir("over");
721 std::fs::write(dir.join(".gitignore"), "build/\n").unwrap();
722 std::fs::create_dir_all(dir.join("build")).unwrap();
723 let mut ign = Ignores::new(&dir, &spec(&["!build/", "*.log"], true, false));
724 assert!(!ign.matched("build", true), "re-included by the client");
725 assert!(ign.matched("a.log", false));
726 }
727
728 #[test]
729 fn nested_ignore_files_stack_deepest_first() {
730 let dir = temp_dir("stack");
731 std::fs::create_dir_all(dir.join("sub")).unwrap();
732 std::fs::write(dir.join(".gitignore"), "*.tmp\n").unwrap();
733 std::fs::write(dir.join("sub/.gitignore"), "!keep.tmp\n").unwrap();
734 let mut ign = Ignores::new(&dir, &spec(&[], true, false));
735 assert!(ign.matched("a.tmp", false));
736 assert!(ign.matched("sub/other.tmp", false));
737 assert!(!ign.matched("sub/keep.tmp", false), "deeper file wins");
738 }
739
740 #[test]
741 fn an_excluded_directory_excludes_its_subtree() {
742 let dir = temp_dir("subtree");
743 std::fs::create_dir_all(dir.join("target/debug")).unwrap();
744 std::fs::write(dir.join(".gitignore"), "target/\n!target/debug/keep\n").unwrap();
745 let mut ign = Ignores::new(&dir, &spec(&[], true, false));
746 assert!(ign.matched("target", true));
747 assert!(ign.matched("target/debug", true));
748 // git's rule: no negation resurrects a file under an excluded dir.
749 assert!(ign.matched("target/debug/keep", false));
750 }
751
752 /// A directory-only pattern matches what this sync *enumerates* as a
753 /// directory, which includes a symlink to one — git calls that a file,
754 /// but git also does not descend it. Matching git here would leave one
755 /// hole: `build/` could not exclude a symlinked `build`, and the whole
756 /// subtree behind it would still be mirrored.
757 #[test]
758 fn a_directory_only_pattern_matches_directories_including_symlinked_ones() {
759 let dir = temp_dir("dironly");
760 let mut ign = Ignores::new(&dir, &spec(&["build/"], false, false));
761 assert!(ign.matched("build", true));
762 assert!(!ign.matched("build", false));
763 }
764
765 #[test]
766 fn invalidate_picks_up_an_edited_ignore_file() {
767 let dir = temp_dir("edit");
768 std::fs::write(dir.join(".gitignore"), "a.txt\n").unwrap();
769 let mut ign = Ignores::new(&dir, &spec(&[], true, false));
770 assert!(ign.matched("a.txt", false));
771 assert!(!ign.matched("b.txt", false));
772 std::fs::write(dir.join(".gitignore"), "b.txt\n").unwrap();
773 assert!(ign.matched("a.txt", false), "still the memoized stack");
774 ign.invalidate();
775 assert!(!ign.matched("a.txt", false));
776 assert!(ign.matched("b.txt", false));
777 let mut src = |rel: &str| ign.source_affects_rules(&dir.join(rel), rel);
778 assert!(src(".gitignore"));
779 assert!(src("sub/.ignore"));
780 assert!(!src("sub/notes.txt"));
781 }
782
783 #[test]
784 fn ignore_files_above_the_root_still_apply() {
785 let top = temp_dir("parent");
786 std::fs::create_dir_all(top.join(".git")).unwrap();
787 std::fs::create_dir_all(top.join("crates")).unwrap();
788 std::fs::write(top.join(".gitignore"), "*.bak\n").unwrap();
789 std::fs::write(top.join(".git/info-placeholder"), "").unwrap();
790 let root = top.join("crates");
791 let mut ign = Ignores::new(&root, &spec(&[], true, false));
792 assert!(
793 ign.matched("a.bak", false),
794 "inherited from the worktree top"
795 );
796 }
797
798 /// An enclosing worktree's `info/exclude` is anchored at the worktree
799 /// top, which is what git anchors it at — not at the subdirectory being
800 /// synced. Anchoring it at the root instead excluded `<root>/build` for a
801 /// `/build` rule that only ever meant `<top>/build`, and let the path git
802 /// really excludes through. Only anchored patterns show it, which is why
803 /// the `*.bak` case above passes either way.
804 #[test]
805 fn an_enclosing_info_exclude_is_anchored_at_the_worktree_top() {
806 let top = temp_dir("infoexcl-anchor");
807 std::fs::create_dir_all(top.join(".git/info")).unwrap();
808 std::fs::create_dir_all(top.join("crates/build")).unwrap();
809 std::fs::create_dir_all(top.join("build")).unwrap();
810 // Anchored (leading slash) and embedded-slash rules, both of which
811 // git reads relative to the worktree top.
812 std::fs::write(top.join(".git/info/exclude"), "/build\ncrates/gen\n").unwrap();
813
814 // Syncing the whole worktree: `/build` is the top's own.
815 let mut whole = Ignores::new(&top, &spec(&[], true, false));
816 assert!(whole.matched("build", true));
817 assert!(!whole.matched("crates/build", true));
818 assert!(whole.matched("crates/gen", true));
819
820 // Syncing `crates`: both rules still resolve against the top, so
821 // `/build` names a path outside this sync and `crates/gen` names one
822 // inside it — the sync sees exactly what git would ignore, not what
823 // the same text would mean if it were re-anchored at the root.
824 let root = top.join("crates");
825 let mut sub = Ignores::new(&root, &spec(&[], true, false));
826 assert!(
827 !sub.matched("build", true),
828 "/build in the top's info/exclude is <top>/build, not <root>/build"
829 );
830 assert!(
831 sub.matched("gen", true),
832 "crates/gen resolves to this root's gen"
833 );
834 }
835
836 #[test]
837 fn info_exclude_is_honored_and_is_a_source() {
838 let dir = temp_dir("infoexcl");
839 std::fs::create_dir_all(dir.join(".git/info")).unwrap();
840 std::fs::write(dir.join(".git/info/exclude"), "secret*\n").unwrap();
841 let mut ign = Ignores::new(&dir, &spec(&[], true, true));
842 assert!(ign.matched("secret.txt", false));
843 assert!(ign.is_source_abs(&dir.join(".git/info/exclude")));
844 assert!(
845 ign.source_affects_rules(&dir.join(".git/info/exclude"), ".git/info/exclude"),
846 "the root's own info/exclude matters even though .git is excluded"
847 );
848 }
849
850 /// A repository nested inside the root is its own scope: git does not
851 /// apply an outer repository's rules inside an inner one, and neither
852 /// does this. The outer repo can still exclude the nested directory
853 /// itself — that entry is decided by its parent's stack.
854 #[test]
855 fn a_nested_repository_starts_a_fresh_stack() {
856 let dir = temp_dir("nested");
857 std::fs::create_dir_all(dir.join(".git")).unwrap();
858 std::fs::write(dir.join(".gitignore"), "*.rs\n").unwrap();
859 std::fs::create_dir_all(dir.join("vendor/lib/.git/info")).unwrap();
860 std::fs::write(dir.join("vendor/lib/.gitignore"), "*.txt\n").unwrap();
861 std::fs::write(dir.join("vendor/lib/.git/info/exclude"), "local-*\n").unwrap();
862 let mut ign = Ignores::new(&dir, &spec(&[], true, false));
863
864 assert!(ign.matched("a.rs", false), "the outer rule, outside");
865 assert!(
866 !ign.matched("vendor/lib/a.rs", false),
867 "an outer repository's rules do not reach into a nested one"
868 );
869 assert!(ign.matched("vendor/lib/b.txt", false), "the inner rule");
870 assert!(
871 ign.matched("vendor/lib/local-notes.md", false),
872 "the nested repository's own info/exclude"
873 );
874 assert!(
875 ign.is_source_abs(&dir.join("vendor/lib/.git/info/exclude")),
876 "editing it has to invalidate the stack"
877 );
878 // Directories between the two tops still belong to the outer repo.
879 assert!(ign.matched("vendor/c.rs", false));
880 }
881
882 /// A root that is itself a repository top inherits nothing from a
883 /// repository it happens to sit inside.
884 #[test]
885 fn a_root_that_is_a_repo_top_ignores_the_outer_repo() {
886 let outer = temp_dir("outertop");
887 std::fs::create_dir_all(outer.join(".git")).unwrap();
888 std::fs::write(outer.join(".gitignore"), "*.bak\n").unwrap();
889 let inner = outer.join("inner");
890 std::fs::create_dir_all(inner.join(".git")).unwrap();
891 let mut ign = Ignores::new(&inner, &spec(&[], true, false));
892 assert!(!ign.matched("a.bak", false));
893
894 // …while a plain subdirectory of the outer repo does inherit it.
895 let plain = outer.join("plain");
896 std::fs::create_dir_all(&plain).unwrap();
897 let mut ign = Ignores::new(&plain, &spec(&[], true, false));
898 assert!(ign.matched("a.bak", false));
899 }
900
901 /// A pure exclusion list commutes, so equivalent specs normalize to
902 /// one key and share a root; a list with a negation in it does not,
903 /// since gitignore is last-match-wins.
904 #[test]
905 fn pattern_order_normalizes_only_when_it_carries_no_meaning() {
906 assert_eq!(
907 IgnoreSpec::parse_patterns("b\na\nb"),
908 IgnoreSpec::parse_patterns("a\nb"),
909 "reordered and duplicated exclusions are the same request"
910 );
911 assert_eq!(
912 IgnoreSpec::parse_patterns("*.log\n!keep.log"),
913 vec!["*.log", "!keep.log"],
914 "a negation pins the order — sorting it would invert the rule"
915 );
916 assert_ne!(
917 IgnoreSpec::parse_patterns("!keep.log\n*.log"),
918 IgnoreSpec::parse_patterns("*.log\n!keep.log"),
919 );
920 }
921
922 /// The two ignore-file kinds are selectable independently, and only
923 /// `.gitignore` brings git's repository-wide sources with it.
924 #[test]
925 fn the_two_ignore_file_kinds_are_independent() {
926 let dir = temp_dir("kinds");
927 std::fs::create_dir_all(dir.join(".git/info")).unwrap();
928 std::fs::write(dir.join(".git/info/exclude"), "excluded-*\n").unwrap();
929 std::fs::write(dir.join(".gitignore"), "from-git\n").unwrap();
930 std::fs::write(dir.join(".ignore"), "from-dot\n").unwrap();
931
932 let only_git = IgnoreSpec {
933 gitignore: true,
934 ..Default::default()
935 };
936 let mut ign = Ignores::new(&dir, &only_git);
937 assert!(ign.matched("from-git", false));
938 assert!(!ign.matched("from-dot", false));
939 assert!(ign.matched("excluded-x", false), "info/exclude is git's");
940
941 let only_dot = IgnoreSpec {
942 dot_ignore: true,
943 ..Default::default()
944 };
945 let mut ign = Ignores::new(&dir, &only_dot);
946 assert!(!ign.matched("from-git", false));
947 assert!(ign.matched("from-dot", false));
948 assert!(
949 !ign.matched("excluded-x", false),
950 "`.ignore` alone reads no git sources"
951 );
952 assert!(
953 !ign.source_affects_rules(&dir.join(".gitignore"), ".gitignore"),
954 "a file this spec never reads is not one of its sources"
955 );
956 }
957
958 /// `core.ignorecase` is what git matches by on a case-insensitive
959 /// filesystem, so a mirror that ignored it would exclude a different
960 /// set of paths than the repository it mirrors.
961 #[test]
962 fn core_ignorecase_folds_the_matchers() {
963 let dir = temp_dir("icase");
964 std::fs::create_dir_all(dir.join(".git")).unwrap();
965 std::fs::write(dir.join(".gitignore"), "Build/\n*.LOG\n").unwrap();
966
967 std::fs::write(
968 dir.join(".git/config"),
969 "[core]\n\trepositoryformatversion = 0\n",
970 )
971 .unwrap();
972 let mut ign = Ignores::new(&dir, &spec(&["Vendor"], true, false));
973 assert!(!ign.matched("build", true), "case-sensitive by default");
974 assert!(!ign.matched("a.log", false));
975 assert!(!ign.matched("vendor", true));
976
977 std::fs::write(dir.join(".git/config"), "[core]\n\tignorecase = true\n").unwrap();
978 let mut ign = Ignores::new(&dir, &spec(&["Vendor"], true, false));
979 assert!(ign.matched("build", true));
980 assert!(ign.matched("a.log", false));
981 assert!(ign.matched("vendor", true), "client patterns fold too");
982
983 // git's boolean spellings, and a section that is not `[core]`.
984 std::fs::write(dir.join(".git/config"), "[core]\nignorecase = false\n").unwrap();
985 assert!(!Ignores::new(&dir, &spec(&[], true, false)).matched("build", true));
986 std::fs::write(dir.join(".git/config"), "[core]\nignorecase\n").unwrap();
987 assert!(Ignores::new(&dir, &spec(&[], true, false)).matched("build", true));
988 std::fs::write(dir.join(".git/config"), "[other]\nignorecase = true\n").unwrap();
989 assert!(!Ignores::new(&dir, &spec(&[], true, false)).matched("build", true));
990 }
991
992 #[test]
993 fn nothing_configured_matches_nothing() {
994 let dir = temp_dir("empty");
995 assert!(IgnoreSpec::default().is_empty());
996 let mut ign = Ignores::new(&dir, &IgnoreSpec::default());
997 assert!(!ign.matched("anything/at/all", false));
998 assert!(!ign.source_affects_rules(&dir.join(".gitignore"), ".gitignore"));
999 }
1000
1001 /// An ignore file inside an already-excluded directory is never read,
1002 /// so writing one changes nothing and must not cost a rebuild plus a
1003 /// full re-enumeration. `npm install` writes thousands of them.
1004 #[test]
1005 fn an_ignore_file_under_an_excluded_directory_changes_no_rules() {
1006 let dir = temp_dir("deadsrc");
1007 std::fs::create_dir_all(dir.join(".git/info")).unwrap();
1008 std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
1009 std::fs::create_dir_all(dir.join("src")).unwrap();
1010 std::fs::write(dir.join(".gitignore"), "node_modules/\n").unwrap();
1011 let mut ign = Ignores::new(&dir, &spec(&[], true, true));
1012 let affects = |ign: &mut Ignores, rel: &str| ign.source_affects_rules(&dir.join(rel), rel);
1013
1014 assert!(affects(&mut ign, ".gitignore"), "the root's own");
1015 assert!(affects(&mut ign, "src/.gitignore"), "a live subdirectory");
1016 assert!(
1017 !affects(&mut ign, "node_modules/.gitignore"),
1018 "inside an excluded directory: never read, so never a rebuild"
1019 );
1020 assert!(!affects(&mut ign, "node_modules/pkg/.gitignore"));
1021 assert!(!affects(&mut ign, "src/notes.txt"), "not a source at all");
1022
1023 // A nested repository's info/exclude follows its *worktree*, not
1024 // its own directory — which `EXCLUDE_GIT` always excludes.
1025 std::fs::create_dir_all(dir.join("vendor/lib/.git/info")).unwrap();
1026 assert!(affects(&mut ign, "vendor/lib/.git/info/exclude"));
1027 assert!(
1028 !affects(&mut ign, "node_modules/pkg/.git/info/exclude"),
1029 "a repository inside an excluded directory is not indexed either"
1030 );
1031 }
1032}