Skip to main content

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}