Skip to main content

grit_lib/
ignore.rs

1//! Ignore and exclude matching for `check-ignore`.
2//!
3//! This module implements a focused subset of Git ignore behavior:
4//! per-directory `.gitignore`, `.git/info/exclude`, and `core.excludesfile`
5//! with "last matching pattern wins" precedence. With `core.ignorecase=true`, patterns match
6//! without regard to ASCII case, as in Git.
7
8use std::collections::HashMap;
9use std::fmt;
10use std::fs;
11use std::path::{Component, Path, PathBuf};
12
13use crate::config::parse_path;
14use crate::error::{Error, Result};
15use crate::index::{Index, MODE_GITLINK};
16use crate::objects::ObjectKind;
17use crate::repo::Repository;
18use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
19
20/// Where an ignore pattern was loaded from.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum IgnoreSource {
23    /// A per-directory exclude file (typically `.gitignore`).
24    PerDirectory(String),
25    /// Patterns from `.git/info/exclude`.
26    InfoExclude,
27    /// Patterns from `core.excludesfile` (or the XDG default).
28    CoreExcludesFile(String),
29}
30
31impl fmt::Display for IgnoreSource {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::PerDirectory(path) => f.write_str(path),
35            Self::InfoExclude => f.write_str(".git/info/exclude"),
36            Self::CoreExcludesFile(path) => f.write_str(path),
37        }
38    }
39}
40
41/// Metadata for a matching rule.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct IgnoreMatch {
44    /// Typed source of the matching pattern (see [`IgnoreSource`]).
45    pub source: IgnoreSource,
46    /// Line number in the source file (1-based).
47    pub line_number: usize,
48    /// Pattern text as written (excluding comments/blank lines).
49    pub pattern_text: String,
50    /// Whether this is a negated pattern (`!pattern`).
51    pub negative: bool,
52}
53
54/// Result of checking one path against repository ignore rules.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct IgnoreCheck {
57    /// Repository-relative path that was queried.
58    pub path: String,
59    /// Whether the path is ignored (always `false` for tracked paths).
60    pub ignored: bool,
61    /// Last matching pattern metadata, when Git would attribute one (`check-ignore -v`).
62    pub matched: Option<IgnoreMatch>,
63}
64
65/// Patterns parsed from one exclude file (such as a `.gitignore`), for matching single paths.
66///
67/// Unlike [`IgnoreMatcher`], a list is not tied to a repository: the caller decides which
68/// files apply to a path and in what order, as tools that layer their own ignore handling on
69/// top of Git's file format need to. Matching follows Git's `last_matching_pattern_from_list`
70/// for one path: patterns without a slash match the path's last component, other patterns match
71/// the whole path relative to the list's base directory, and directory-only patterns (`foo/`)
72/// match only when the path is a directory. Parent directories are not consulted; callers that
73/// walk a tree stop descending into ignored directories, as Git does.
74///
75/// # Examples
76///
77/// ```
78/// use grit_lib::worktree::ignore::IgnorePatternList;
79///
80/// let list = IgnorePatternList::parse(b"*.log\n!keep.log\nbuild/\n", "");
81/// assert!(list.is_ignored("debug.log", false, false).unwrap());
82/// assert!(!list.is_ignored("keep.log", false, false).unwrap());
83/// assert!(list.is_ignored("build", true, false).unwrap());
84/// assert_eq!(list.is_ignored("build", false, false), None);
85/// ```
86#[derive(Debug, Clone, Default)]
87pub struct IgnorePatternList {
88    rules: Vec<IgnoreRule>,
89}
90
91/// The pattern that decided an [`IgnorePatternList::last_match`] query.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct IgnorePatternMatch {
94    /// Line number of the pattern in the parsed content (1-based).
95    pub line_number: usize,
96    /// Pattern text as written (without trailing whitespace).
97    pub pattern_text: String,
98    /// Whether this is a negated pattern (`!pattern`), which re-includes the path.
99    pub negative: bool,
100}
101
102impl IgnorePatternList {
103    /// Parse exclude-file `content` whose patterns are relative to `base_dir`.
104    ///
105    /// `base_dir` is the slash-separated directory holding the file, relative to the root
106    /// paths are matched against (`""` for the root). Blank lines and comments are skipped,
107    /// a UTF-8 byte order mark on the first line is dropped, and invalid UTF-8 is replaced
108    /// as in [`String::from_utf8_lossy`].
109    #[must_use]
110    pub fn parse(content: &[u8], base_dir: &str) -> Self {
111        let content = String::from_utf8_lossy(content);
112        let base_dir = base_dir.trim_matches('/');
113        let source = IgnoreSource::PerDirectory(if base_dir.is_empty() {
114            ".gitignore".to_owned()
115        } else {
116            format!("{base_dir}/.gitignore")
117        });
118        let mut warnings = Vec::new();
119        let rules =
120            parse_gitignore_content(&content, source, base_dir, &mut warnings).unwrap_or_default();
121        Self { rules }
122    }
123
124    /// Whether the list has no patterns.
125    #[must_use]
126    pub fn is_empty(&self) -> bool {
127        self.rules.is_empty()
128    }
129
130    /// Number of patterns in the list.
131    #[must_use]
132    pub fn len(&self) -> usize {
133        self.rules.len()
134    }
135
136    /// The last pattern matching `path`, if any.
137    ///
138    /// `path` is slash-separated and relative to the root the list's base directory is
139    /// relative to. Paths outside the base directory never match. With `ignore_case`,
140    /// patterns compare without regard to ASCII case (`core.ignorecase`).
141    #[must_use]
142    pub fn last_match(
143        &self,
144        path: &str,
145        is_dir: bool,
146        ignore_case: bool,
147    ) -> Option<IgnorePatternMatch> {
148        self.rules
149            .iter()
150            .rev()
151            .find(|rule| rule_matches(rule, path, is_dir, ignore_case))
152            .map(|rule| IgnorePatternMatch {
153                line_number: rule.line_number,
154                pattern_text: rule.pattern_text.clone(),
155                negative: rule.negative,
156            })
157    }
158
159    /// Whether the last matching pattern ignores `path` (`Some(false)` when it re-includes
160    /// it), or `None` when no pattern matches. See [`Self::last_match`].
161    #[must_use]
162    pub fn is_ignored(&self, path: &str, is_dir: bool, ignore_case: bool) -> Option<bool> {
163        self.last_match(path, is_dir, ignore_case)
164            .map(|m| !m.negative)
165    }
166}
167
168#[derive(Debug, Clone)]
169enum IgnorePatternKind {
170    Wild,
171    Literal(String),
172    Prefix(String),
173    Suffix(String),
174}
175
176#[derive(Debug, Clone)]
177struct IgnoreRule {
178    source: IgnoreSource,
179    line_number: usize,
180    pattern_text: String,
181    negative: bool,
182    directory_only: bool,
183    anchored: bool,
184    has_slash: bool,
185    body: String,
186    base_dir: String,
187    match_kind: IgnorePatternKind,
188}
189
190/// Engine used to evaluate ignore patterns against repository-relative paths.
191#[derive(Debug, Default, Clone)]
192pub struct IgnoreMatcher {
193    /// Patterns from `git ls-files -x` / `--exclude` (Git `EXC_CMDL`), evaluated first.
194    cli_rules: Vec<IgnoreRule>,
195    global_rules: Vec<IgnoreRule>,
196    info_rules: Vec<IgnoreRule>,
197    /// Patterns from `git ls-files -X` / `--exclude-from` (Git `EXC_FILE`, after global/info).
198    exclude_from_rules: Vec<IgnoreRule>,
199    gitignore_cache: HashMap<String, Vec<IgnoreRule>>,
200    /// Per-directory exclude filename (`ls-files --exclude-per-directory`, default `.gitignore`).
201    per_directory_name: Option<String>,
202    /// Whether per-directory exclude files are read at all (Git `EXC_DIRS`).
203    ///
204    /// `from_repository` (standard excludes) and an explicit `--exclude-per-directory`/`--ignored`
205    /// enable this; a matcher built only for `-x`/`-X` does not consult per-directory files.
206    read_per_directory: bool,
207    /// Warnings emitted while loading in-tree `.gitignore` (e.g. symlink paths).
208    pub warnings: Vec<String>,
209    /// Cached ancestor directory list for the last [`Self::ensure_per_dir_rules_loaded`] parent.
210    per_dir_dirs_cache: Option<(String, Vec<String>)>,
211    /// Per-directory ancestor exclude stacks reused across sibling checks under the same prefix.
212    dir_exclude_stack_cache: HashMap<String, Vec<String>>,
213    /// Fold ASCII case when comparing patterns and base directories (`core.ignorecase`).
214    ignore_case: bool,
215    /// Repository-relative directories entered during a depth-first walk (skip restat in EXC_DIRS).
216    known_worktree_directories: std::collections::HashSet<String>,
217}
218
219impl IgnoreMatcher {
220    /// Build a matcher from repository exclude sources.
221    ///
222    /// Honors `core.ignorecase`: when it is `true`, patterns match paths without regard to
223    /// ASCII case (`Build/` ignores `build/`, `*.LOG` ignores `debug.log`), as Git does.
224    ///
225    /// # Parameters
226    ///
227    /// - `repo` - open repository.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`Error::Io`] if configured pattern files cannot be read, or a config error if
232    /// the repository configuration cannot be loaded.
233    pub fn from_repository(repo: &Repository) -> Result<Self> {
234        let ignore_case = crate::crlf::config_ignorecase(repo.config()?.as_ref());
235        Ok(Self {
236            global_rules: load_global_excludes(repo)?,
237            info_rules: load_info_excludes(repo)?,
238            // Standard excludes include the per-directory `.gitignore` set (Git `EXC_DIRS`).
239            read_per_directory: true,
240            ignore_case,
241            ..Self::default()
242        })
243    }
244
245    /// Set whether patterns match case-insensitively (Git's `core.ignorecase`).
246    ///
247    /// [`Self::from_repository`] reads this from configuration; use this to override it, or to
248    /// opt in on a matcher built with [`Default::default`]. Only ASCII letters are folded, like
249    /// Git.
250    ///
251    /// # Parameters
252    ///
253    /// - `ignore_case` - `true` to fold ASCII case when matching.
254    ///
255    /// # Returns
256    ///
257    /// The matcher with the setting applied.
258    #[must_use]
259    pub fn with_ignore_case(mut self, ignore_case: bool) -> Self {
260        self.ignore_case = ignore_case;
261        self
262    }
263
264    /// Whether patterns match case-insensitively (see [`Self::with_ignore_case`]).
265    #[must_use]
266    pub fn ignore_case(&self) -> bool {
267        self.ignore_case
268    }
269
270    /// Append patterns from `ls-files --exclude-from` / `-X` files (relative paths resolve from `cwd`).
271    ///
272    /// Matches Git's `EXC_FILE` lists loaded after `core.excludesfile` and `.git/info/exclude`.
273    pub fn add_exclude_from_files(&mut self, paths: &[PathBuf], cwd: &Path) -> Result<()> {
274        for path in paths {
275            let resolved = if path.is_absolute() {
276                path.clone()
277            } else {
278                cwd.join(path)
279            };
280            let display = path.display().to_string();
281            let mut more = load_rules_from_file(
282                &resolved,
283                IgnoreSource::CoreExcludesFile(display),
284                String::new(),
285                false,
286                &mut self.warnings,
287            )?;
288            self.exclude_from_rules.append(&mut more);
289        }
290        Ok(())
291    }
292
293    /// Override the per-directory exclude filename (`ls-files --exclude-per-directory=<file>`).
294    ///
295    /// When unset, per-directory excludes are read from `.gitignore` (Git's standard name).
296    pub fn set_per_directory_name(&mut self, name: &str) {
297        self.per_directory_name = Some(name.to_owned());
298        self.read_per_directory = true;
299        // The cache is keyed by directory only; clear it so a new filename is honored.
300        self.gitignore_cache.clear();
301        self.per_dir_dirs_cache = None;
302        self.dir_exclude_stack_cache.clear();
303    }
304
305    /// Append patterns from `ls-files --exclude` / `-x` (Git command-line exclude list).
306    pub fn add_cli_excludes(&mut self, patterns: &[String]) {
307        for pat in patterns {
308            if let Some(rule) = parse_rule_line(
309                pat,
310                1,
311                IgnoreSource::PerDirectory("--exclude option".into()),
312                "",
313            ) {
314                self.cli_rules.push(rule);
315            }
316        }
317    }
318
319    /// Take any warnings accumulated while loading ignore files (caller prints to stderr).
320    #[must_use]
321    pub fn take_warnings(&mut self) -> Vec<String> {
322        std::mem::take(&mut self.warnings)
323    }
324
325    /// Whether `repo_rel_path` is ignored, without computing verbose `-v` match metadata.
326    ///
327    /// Prefer this on hot paths (for example untracked directory walks) that only need the
328    /// boolean result.
329    pub fn is_path_ignored(
330        &mut self,
331        repo: &Repository,
332        index: Option<&Index>,
333        repo_rel_path: &str,
334        is_dir: bool,
335    ) -> Result<bool> {
336        if is_tracked(index, repo_rel_path) {
337            return Ok(false);
338        }
339        let per_dir_dirs = self.ensure_per_dir_rules_loaded(repo, index, repo_rel_path)?;
340        let per_dir_groups = self.cached_rule_slices_for_dirs(&per_dir_dirs);
341        if !self.has_any_exclude_rules(&per_dir_groups) {
342            return Ok(false);
343        }
344        if self
345            .first_excluded_ancestor(repo_rel_path, &per_dir_groups)
346            .is_some()
347        {
348            return Ok(true);
349        }
350        Ok(self
351            .evaluate_against_groups(repo_rel_path, is_dir, &per_dir_groups)
352            .0)
353    }
354
355    /// Check whether a repository-relative path is ignored.
356    ///
357    /// # Parameters
358    ///
359    /// - `repo` - repository handle.
360    /// - `index` - optional index; when present, tracked entries are not ignored. The lookup is
361    ///   exact-case even under `core.ignorecase`, like `git check-ignore`; work-tree walks that
362    ///   must treat case variants of tracked paths as tracked check that before asking.
363    /// - `repo_rel_path` - normalized repository-relative path with `/` separators.
364    /// - `is_dir` - whether the queried path is a directory.
365    ///
366    /// # Returns
367    ///
368    /// Tuple `(ignored, match_info)` where `match_info` is the last matching
369    /// pattern (including negated matches).
370    ///
371    /// # Errors
372    ///
373    /// Returns [`Error::Io`] when a relevant `.gitignore` cannot be read.
374    pub fn check_path(
375        &mut self,
376        repo: &Repository,
377        index: Option<&Index>,
378        repo_rel_path: &str,
379        is_dir: bool,
380    ) -> Result<(bool, Option<IgnoreMatch>)> {
381        if is_tracked(index, repo_rel_path) {
382            return Ok((false, None));
383        }
384
385        // Git evaluates exclude pattern groups in priority order
386        // (`last_matching_pattern_from_lists`, dir.c):
387        //   1. EXC_CMDL  — command-line `-x`/`--exclude` patterns
388        //   2. EXC_DIRS  — per-directory `.gitignore` (deepest directory wins)
389        //   3. EXC_FILE  — `-X`/`--exclude-from`, then `core.excludesfile`, then `.git/info/exclude`
390        // The FIRST group that yields a match decides the result; within a group, the last
391        // matching pattern (last line) wins. This means a CLI `--exclude` overrides a per-directory
392        // `!negation`, and a per-directory `.gitignore` overrides `core.excludesfile`.
393        let per_dir_dirs = self.ensure_per_dir_rules_loaded(repo, index, repo_rel_path)?;
394        let per_dir_groups = self.cached_rule_slices_for_dirs(&per_dir_dirs);
395
396        // Git's `prep_exclude` (dir.c) descends into the path's ancestor directories before
397        // matching the leaf. If an ancestor directory itself matches a non-negative exclude
398        // pattern, the entire subtree is excluded and the walk stops: a later `!one/a.1`
399        // negation can never re-include a file once its parent directory `one` is excluded
400        // ("It is not possible to re-include a file if a parent directory of that file is
401        // excluded", gitignore(5)). When checking ancestor `a/b`, only the per-directory
402        // `.gitignore` files strictly above it are loaded yet, so we slice `per_dir_groups`
403        // to the ancestor's own depth.
404        if let Some(ancestor_match) = self.first_excluded_ancestor(repo_rel_path, &per_dir_groups) {
405            return Ok((true, Some(ancestor_match)));
406        }
407
408        let (ignored, matched) =
409            self.evaluate_against_groups(repo_rel_path, is_dir, &per_dir_groups);
410
411        // For `check-ignore -v` attribution, the verbose refinement walks the full flattened set.
412        let all_rules: Vec<&IgnoreRule> = self.flatten_groups(&per_dir_groups);
413        let matched = refine_match_for_check_ignore_verbose(
414            repo_rel_path,
415            is_dir,
416            ignored,
417            matched,
418            &all_rules,
419            self.ignore_case,
420        );
421
422        Ok((ignored, matched))
423    }
424
425    /// Build the ordered group list (EXC_CMDL, EXC_DIRS deepest-first, EXC_FILE) for a given
426    /// set of per-directory rule groups, then run Git's first-group-wins / last-pattern-wins
427    /// evaluation against `path`.
428    ///
429    /// `per_dir_groups` is the root-first slice of `.gitignore` rule lists that should be
430    /// considered for this query: callers pass the leaf's full slice for the path itself, or a
431    /// shorter prefix slice when probing an ancestor directory (matching `prep_exclude`'s
432    /// incremental loading order).
433    fn evaluate_against_groups(
434        &self,
435        path: &str,
436        is_dir: bool,
437        per_dir_groups: &[&[IgnoreRule]],
438    ) -> (bool, Option<IgnoreMatch>) {
439        let groups = self.build_groups(per_dir_groups);
440        for group in &groups {
441            let mut group_match: Option<&&IgnoreRule> = None;
442            for rule in group {
443                if rule_matches(rule, path, is_dir, self.ignore_case) {
444                    group_match = Some(rule);
445                }
446            }
447            if let Some(rule) = group_match {
448                return (
449                    !rule.negative,
450                    Some(IgnoreMatch {
451                        source: rule.source.clone(),
452                        line_number: rule.line_number,
453                        pattern_text: rule.pattern_text.clone(),
454                        negative: rule.negative,
455                    }),
456                );
457            }
458        }
459        (false, None)
460    }
461
462    /// Flatten the ordered group list into a single rule slice (used for `check-ignore -v`
463    /// attribution, which walks every loaded pattern).
464    fn flatten_groups<'a>(&'a self, per_dir_groups: &[&'a [IgnoreRule]]) -> Vec<&'a IgnoreRule> {
465        self.build_groups(per_dir_groups)
466            .into_iter()
467            .flatten()
468            .collect()
469    }
470
471    /// Assemble the priority-ordered exclude groups: EXC_CMDL, then per-directory `.gitignore`
472    /// (deepest directory first), then EXC_FILE (`--exclude-from`, global, info).
473    fn build_groups<'a>(&'a self, per_dir_groups: &[&'a [IgnoreRule]]) -> Vec<Vec<&'a IgnoreRule>> {
474        let cmdl_rules: Vec<&IgnoreRule> = self.cli_rules.iter().collect();
475        let mut dir_groups: Vec<Vec<&IgnoreRule>> = Vec::new();
476        for rules in per_dir_groups.iter().rev() {
477            dir_groups.push(rules.iter().collect());
478        }
479        let file_rules: Vec<&IgnoreRule> = self
480            .exclude_from_rules
481            .iter()
482            .chain(self.global_rules.iter())
483            .chain(self.info_rules.iter())
484            .collect();
485
486        let mut groups: Vec<Vec<&IgnoreRule>> = Vec::new();
487        groups.push(cmdl_rules);
488        groups.extend(dir_groups);
489        groups.push(file_rules);
490        groups
491    }
492
493    /// Walk the ancestor directories of `repo_rel_path` from the top down. Returns the matching
494    /// pattern of the first ancestor directory that is positively excluded (Git's `prep_exclude`
495    /// abort), or `None` if no ancestor directory is excluded.
496    ///
497    /// `per_dir_groups` is the root-first list of per-directory rule lists for the leaf path;
498    /// element `i` corresponds to the ancestor directory at depth `i`. When probing the ancestor
499    /// directory at depth `d`, only groups `[0..d]` are loaded (the directory's own `.gitignore`
500    /// has not been read yet), matching Git's incremental load order.
501    fn first_excluded_ancestor(
502        &self,
503        repo_rel_path: &str,
504        per_dir_groups: &[&[IgnoreRule]],
505    ) -> Option<IgnoreMatch> {
506        let parent = parent_dir(repo_rel_path);
507        if parent.is_empty() {
508            return None;
509        }
510        let mut cur = String::new();
511        for (depth, segment) in parent.split('/').enumerate() {
512            if !cur.is_empty() {
513                cur.push('/');
514            }
515            cur.push_str(segment);
516            // Patterns loaded before descending into this directory: per-directory groups for
517            // strictly-shallower directories (root..=depth). `per_dir_groups[0]` is the root
518            // `.gitignore`, so the ancestor at depth `depth` (0-based) sees groups `[0..=depth]`.
519            let upper = (depth + 1).min(per_dir_groups.len());
520            let slice = &per_dir_groups[..upper];
521            let (ignored, matched) = self.evaluate_against_groups(&cur, true, slice);
522            if ignored {
523                return matched;
524            }
525        }
526        None
527    }
528
529    /// Per-directory `.gitignore` rules for the ancestors of `repo_rel_path`, grouped by
530    /// directory and ordered root-first (deepest directory last). The caller evaluates the
531    /// groups deepest-first to honor Git's `EXC_DIRS` precedence (a nested `.gitignore` wins
532    /// over an ancestor's, and over `EXC_FILE` sources).
533    fn has_any_exclude_rules(&self, per_dir_groups: &[&[IgnoreRule]]) -> bool {
534        !self.cli_rules.is_empty()
535            || !self.exclude_from_rules.is_empty()
536            || !self.global_rules.is_empty()
537            || !self.info_rules.is_empty()
538            || per_dir_groups.iter().any(|g| !g.is_empty())
539    }
540
541    /// Load per-directory exclude files for `dir_rel` when depth-first walking the work tree.
542    ///
543    /// Idempotent: repeated calls for the same directory reuse the in-memory cache.
544    pub fn note_directory_entered(
545        &mut self,
546        repo: &Repository,
547        index: Option<&Index>,
548        dir_rel: &str,
549    ) -> Result<()> {
550        if !self.read_per_directory {
551            return Ok(());
552        }
553        if self.gitignore_cache.contains_key(dir_rel) {
554            return Ok(());
555        }
556        self.known_worktree_directories.insert(dir_rel.to_owned());
557        let per_dir_name = self.per_directory_name.clone();
558        let rules = load_gitignore_for_dir(
559            repo,
560            index,
561            dir_rel,
562            per_dir_name.as_deref(),
563            &mut self.warnings,
564        )?;
565        self.gitignore_cache.insert(dir_rel.to_owned(), rules);
566        Ok(())
567    }
568
569    /// Record that the walk entered work-tree directory `dir_rel` without reading its
570    /// per-directory exclude file yet.
571    ///
572    /// The file is read by the first query for a path inside the directory, so a directory
573    /// whose entries are all tracked never opens it (as in Git, where exclude rules are only
574    /// consulted for untracked paths).
575    pub(crate) fn note_directory_known(&mut self, dir_rel: &str) {
576        if self.read_per_directory
577            && !self.gitignore_cache.contains_key(dir_rel)
578            && !self.known_worktree_directories.contains(dir_rel)
579        {
580            self.known_worktree_directories.insert(dir_rel.to_owned());
581        }
582    }
583
584    fn ensure_per_dir_rules_loaded(
585        &mut self,
586        repo: &Repository,
587        index: Option<&Index>,
588        repo_rel_path: &str,
589    ) -> Result<Vec<String>> {
590        if !self.read_per_directory {
591            return Ok(Vec::new());
592        }
593        let parent = parent_dir(repo_rel_path);
594        let dirs = match self.per_dir_dirs_cache.as_ref() {
595            Some((cached, cached_dirs)) if cached == parent => cached_dirs.clone(),
596            _ => {
597                if let Some(cached_dirs) = self.dir_exclude_stack_cache.get(parent) {
598                    self.per_dir_dirs_cache = Some((parent.to_string(), cached_dirs.clone()));
599                    cached_dirs.clone()
600                } else {
601                    let mut dirs = Vec::new();
602                    dirs.push(String::new());
603                    if !parent.is_empty() {
604                        let mut cur = String::new();
605                        for segment in parent.split('/') {
606                            if !cur.is_empty() {
607                                cur.push('/');
608                            }
609                            cur.push_str(segment);
610                            if self.known_worktree_directories.contains(&cur) {
611                                dirs.push(cur.clone());
612                            } else if worktree_path_is_existing_file(repo, &cur) {
613                                // Git does not treat a file path as a directory for EXC_DIRS
614                                // loading (`tracked.txt/.gitignore` must not be probed).
615                                break;
616                            } else {
617                                dirs.push(cur.clone());
618                            }
619                        }
620                    }
621                    self.per_dir_dirs_cache = Some((parent.to_string(), dirs.clone()));
622                    self.dir_exclude_stack_cache
623                        .insert(parent.to_string(), dirs.clone());
624                    dirs
625                }
626            }
627        };
628
629        let per_dir_name = self.per_directory_name.clone();
630        for dir in &dirs {
631            if !self.gitignore_cache.contains_key(dir) {
632                let rules = load_gitignore_for_dir(
633                    repo,
634                    index,
635                    dir,
636                    per_dir_name.as_deref(),
637                    &mut self.warnings,
638                )?;
639                self.gitignore_cache.insert(dir.clone(), rules);
640            }
641        }
642        Ok(dirs)
643    }
644
645    fn cached_rule_slices_for_dirs(&self, dirs: &[String]) -> Vec<&[IgnoreRule]> {
646        dirs.iter()
647            .map(|dir| {
648                self.gitignore_cache
649                    .get(dir)
650                    .map_or(&[] as &[IgnoreRule], Vec::as_slice)
651            })
652            .collect()
653    }
654}
655
656fn load_global_excludes(repo: &Repository) -> Result<Vec<IgnoreRule>> {
657    let config = repo.config()?;
658    let env = repo.environment();
659    let (resolved, display_path) = if let Some(raw_path) = config.get("core.excludesfile") {
660        let expanded = parse_path(env, &raw_path);
661        let resolved = if Path::new(&expanded).is_absolute() {
662            PathBuf::from(&expanded)
663        } else if let Some(work_tree) = &repo.work_tree() {
664            work_tree.join(&expanded)
665        } else {
666            repo.git_dir().join(&expanded)
667        };
668        (resolved, raw_path)
669    } else if let Some(path) = env.default_xdg_config_git_file("ignore") {
670        let display = path.display().to_string();
671        (path, display)
672    } else {
673        return Ok(Vec::new());
674    };
675
676    let mut sink = Vec::new();
677    load_rules_from_file(
678        &resolved,
679        IgnoreSource::CoreExcludesFile(display_path),
680        String::new(),
681        false,
682        &mut sink,
683    )
684}
685
686fn load_info_excludes(repo: &Repository) -> Result<Vec<IgnoreRule>> {
687    let path = repo.git_dir().join("info/exclude");
688    let mut sink = Vec::new();
689    load_rules_from_file(
690        &path,
691        IgnoreSource::InfoExclude,
692        String::new(),
693        false,
694        &mut sink,
695    )
696}
697
698fn load_gitignore_for_dir(
699    repo: &Repository,
700    index: Option<&Index>,
701    dir: &str,
702    per_dir_name: Option<&str>,
703    warnings: &mut Vec<String>,
704) -> Result<Vec<IgnoreRule>> {
705    let Some(work_tree) = &repo.work_tree() else {
706        return Ok(Vec::new());
707    };
708    let file_name = per_dir_name.unwrap_or(".gitignore");
709    let path = if dir.is_empty() {
710        work_tree.join(file_name)
711    } else {
712        work_tree.join(dir).join(file_name)
713    };
714    let source_display = if dir.is_empty() {
715        file_name.to_owned()
716    } else {
717        format!("{dir}/{file_name}")
718    };
719    let source = IgnoreSource::PerDirectory(source_display.clone());
720    let rel_key = if dir.is_empty() {
721        file_name.to_owned()
722    } else {
723        format!("{dir}/{file_name}")
724    };
725
726    // In-tree `.gitignore` must not be a symlink (Git follows symlinks for global/info excludes
727    // only). Match Git's warning and skip the file (t0008).
728    match read_in_tree_pattern_file(&path)? {
729        InTreePatternFile::Symlink => {
730            warnings.push(crate::diagnostics::warning_line(&format!(
731                "unable to access '{source_display}': Too many levels of symbolic links"
732            )));
733            return Ok(Vec::new());
734        }
735        InTreePatternFile::Content(content) => {
736            return parse_gitignore_content(&content, source, dir, warnings);
737        }
738        InTreePatternFile::Missing => {}
739    }
740
741    if let Some(ix) = index {
742        if let Some(entry) = ix.get(rel_key.as_bytes(), 0) {
743            if let Ok(obj) = repo.odb().read(&entry.oid) {
744                if obj.kind == ObjectKind::Blob {
745                    if let Ok(text) = std::str::from_utf8(&obj.data) {
746                        return parse_gitignore_content(text, source, dir, warnings);
747                    }
748                }
749            }
750        }
751    }
752
753    Ok(Vec::new())
754}
755
756fn parse_gitignore_content(
757    content: &str,
758    source: IgnoreSource,
759    base_dir: &str,
760    _warnings: &mut Vec<String>,
761) -> Result<Vec<IgnoreRule>> {
762    let mut rules = Vec::new();
763    // Split on LF only: like Git's `add_patterns_from_buffer`, a line loses at most one
764    // trailing CR (in `parse_rule_line`), so `a\r\r` names the file `a\r`.
765    for (idx, line) in content.split('\n').enumerate() {
766        let line = if idx == 0 {
767            line.strip_prefix('\u{feff}').unwrap_or(line)
768        } else {
769            line
770        };
771        if let Some(rule) = parse_rule_line(line, idx + 1, source.clone(), base_dir) {
772            rules.push(rule);
773        }
774    }
775    Ok(rules)
776}
777
778fn load_rules_from_file(
779    path: &Path,
780    source: IgnoreSource,
781    base_dir: String,
782    deny_symlink_gitignore: bool,
783    warnings: &mut Vec<String>,
784) -> Result<Vec<IgnoreRule>> {
785    if deny_symlink_gitignore && path.exists() {
786        if let Ok(meta) = fs::symlink_metadata(path) {
787            if meta.file_type().is_symlink() {
788                warnings.push(crate::diagnostics::warning_line(&format!(
789                    "unable to access '{}': Too many levels of symbolic links",
790                    source
791                )));
792                return Ok(Vec::new());
793            }
794        }
795    }
796    if path.exists() {
797        crate::worktree_rules::record_ignore_pattern_file_read(path);
798    }
799    let Some(content) = read_optional_text(path)? else {
800        return Ok(Vec::new());
801    };
802    parse_gitignore_content(&content, source, &base_dir, warnings)
803}
804
805/// Trims only *unescaped* trailing spaces, matching Git's `trim_trailing_spaces` in `dir.c`.
806///
807/// A backslash escapes the following byte; a run of spaces ending the line is removed only
808/// when it is not part of an escape sequence (see t0008 "trailing whitespace is ignored").
809fn trim_trailing_spaces_git(buf: &mut String) {
810    let mut bytes = std::mem::take(buf).into_bytes();
811    let mut last_space_start: Option<usize> = None;
812    let mut i = 0usize;
813    while i < bytes.len() {
814        match bytes[i] {
815            b' ' => {
816                if last_space_start.is_none() {
817                    last_space_start = Some(i);
818                }
819                i += 1;
820            }
821            b'\\' => {
822                last_space_start = None;
823                i += 1;
824                if i < bytes.len() {
825                    i += 1;
826                } else {
827                    *buf = String::from_utf8_lossy(&bytes).into_owned();
828                    return;
829                }
830            }
831            _ => {
832                last_space_start = None;
833                i += 1;
834            }
835        }
836    }
837    if let Some(start) = last_space_start {
838        bytes.truncate(start);
839    }
840    *buf = String::from_utf8_lossy(&bytes).into_owned();
841}
842
843fn parse_rule_line(
844    line: &str,
845    line_number: usize,
846    source: IgnoreSource,
847    base_dir: &str,
848) -> Option<IgnoreRule> {
849    let mut raw_line = line.strip_suffix('\r').unwrap_or(line).to_owned();
850    trim_trailing_spaces_git(&mut raw_line);
851    if raw_line.is_empty() || raw_line.starts_with('#') {
852        return None;
853    }
854
855    let pattern_text = raw_line.clone();
856    let mut raw = raw_line;
857
858    let mut negative = false;
859    if let Some(rest) = raw.strip_prefix('!') {
860        negative = true;
861        raw = rest.to_owned();
862    }
863    if raw.is_empty() {
864        return None;
865    }
866
867    let mut anchored = false;
868    if let Some(rest) = raw.strip_prefix('/') {
869        anchored = true;
870        raw = rest.to_owned();
871    }
872    if raw.is_empty() {
873        return None;
874    }
875
876    let mut directory_only = false;
877    if let Some(rest) = raw.strip_suffix('/') {
878        directory_only = true;
879        raw = rest.to_owned();
880    }
881    if raw.is_empty() {
882        return None;
883    }
884
885    let has_slash = raw.contains('/');
886    let body = raw;
887    let match_kind = classify_ignore_pattern(&body);
888    Some(IgnoreRule {
889        source,
890        line_number,
891        pattern_text,
892        negative,
893        directory_only,
894        anchored,
895        has_slash,
896        body,
897        base_dir: base_dir.to_owned(),
898        match_kind,
899    })
900}
901
902fn classify_ignore_pattern(body: &str) -> IgnorePatternKind {
903    // Backslashes are wildmatch escapes (`foo\ bar` matches a literal space); never treat as
904    // a plain literal string.
905    if body.contains('\\') || body.contains(['?', '[']) {
906        return IgnorePatternKind::Wild;
907    }
908    if !body.contains('*') {
909        return IgnorePatternKind::Literal(body.to_owned());
910    }
911    if let Some(rest) = body.strip_prefix('*') {
912        if !rest.contains('*') && !rest.contains('/') {
913            return IgnorePatternKind::Suffix(rest.to_owned());
914        }
915    }
916    if let Some(rest) = body.strip_suffix('*') {
917        if !rest.contains('*') && !rest.contains('/') {
918            return IgnorePatternKind::Prefix(rest.to_owned());
919        }
920    }
921    IgnorePatternKind::Wild
922}
923
924fn base_dir_depth(base: &str) -> usize {
925    if base.is_empty() {
926        return 0;
927    }
928    base.split('/').count()
929}
930
931/// Git's `check-ignore -v` attributes coverage to a parent `…/` directory rule when a redundant
932/// positive pattern exists in a nested `.gitignore` under an already-ignored directory.
933fn refine_match_for_check_ignore_verbose(
934    repo_rel_path: &str,
935    is_dir: bool,
936    ignored: bool,
937    matched: Option<IgnoreMatch>,
938    rules: &[&IgnoreRule],
939    ignore_case: bool,
940) -> Option<IgnoreMatch> {
941    let m = matched?;
942    if m.negative {
943        return Some(m);
944    }
945    if !ignored {
946        return Some(m);
947    }
948    let mut best: Option<&IgnoreRule> = None;
949    for rule in rules {
950        if !rule.directory_only {
951            continue;
952        }
953        if !rule_matches(rule, repo_rel_path, is_dir, ignore_case) {
954            continue;
955        }
956        match best {
957            None => best = Some(rule),
958            Some(b) if base_dir_depth(&rule.base_dir) < base_dir_depth(&b.base_dir) => {
959                best = Some(rule);
960            }
961            Some(b)
962                if base_dir_depth(&rule.base_dir) == base_dir_depth(&b.base_dir)
963                    && rule.line_number < b.line_number =>
964            {
965                best = Some(rule);
966            }
967            _ => {}
968        }
969    }
970    Some(
971        best.map(|r| IgnoreMatch {
972            source: r.source.clone(),
973            line_number: r.line_number,
974            pattern_text: r.pattern_text.clone(),
975            negative: r.negative,
976        })
977        .unwrap_or(m),
978    )
979}
980
981fn rule_matches(rule: &IgnoreRule, repo_rel_path: &str, is_dir: bool, ignore_case: bool) -> bool {
982    if ignore_case {
983        rule_matches_with::<true>(rule, repo_rel_path, is_dir)
984    } else {
985        rule_matches_with::<false>(rule, repo_rel_path, is_dir)
986    }
987}
988
989/// [`rule_matches`] specialized on case folding so the default case-sensitive path carries
990/// no per-comparison `core.ignorecase` checks.
991///
992/// Only the queried path itself is tested: slash-free patterns match its basename, patterns
993/// with a slash match the whole path relative to the rule's directory. A path under an
994/// excluded directory inherits that exclusion from [`IgnoreMatcher::first_excluded_ancestor`],
995/// which probes each ancestor with the same test, so `tags` followed by `!tags/` leaves
996/// `tags/a.txt` unignored while `tags` alone ignores it through its parent.
997fn rule_matches_with<const FOLD: bool>(
998    rule: &IgnoreRule,
999    repo_rel_path: &str,
1000    is_dir: bool,
1001) -> bool {
1002    // Directory-only patterns (`foo/`) never match files, so `!pkg/` cannot re-include files
1003    // under `pkg/` that `*` still ignores (issue #1650).
1004    if rule.directory_only && !is_dir {
1005        return false;
1006    }
1007    let Some(rel_to_base) = strip_base(&rule.base_dir, repo_rel_path, FOLD) else {
1008        return false;
1009    };
1010    if rel_to_base.is_empty() {
1011        return false;
1012    }
1013    if rule.has_slash || rule.anchored {
1014        return rule_body_matches(rule, rel_to_base, true, FOLD);
1015    }
1016    let basename = rel_to_base.rsplit('/').next().unwrap_or(rel_to_base);
1017    rule_body_matches(rule, basename, false, FOLD)
1018}
1019
1020fn is_tracked(index: Option<&Index>, repo_rel_path: &str) -> bool {
1021    let Some(index) = index else {
1022        return false;
1023    };
1024    // Trailing `/` is a directory query; Git does not apply the index tracked exemption
1025    // (`tracked.txt/` can match exclude rules even when `tracked.txt` is staged).
1026    if repo_rel_path.ends_with('/') {
1027        return false;
1028    }
1029    index.get(repo_rel_path.as_bytes(), 0).is_some()
1030}
1031
1032/// True when `repo_rel_path` exists in the work tree as a regular file (not a directory).
1033fn worktree_path_is_existing_file(repo: &Repository, repo_rel_path: &str) -> bool {
1034    let Some(work_tree) = repo.work_tree() else {
1035        return false;
1036    };
1037    let path = work_tree.join(repo_rel_path);
1038    path.metadata().map(|m| m.is_file()).unwrap_or(false)
1039}
1040
1041fn path_query_is_dir(repo: &Repository, repo_rel_path: &str) -> bool {
1042    if repo_rel_path.ends_with('/') {
1043        return true;
1044    }
1045    let Some(work_tree) = repo.work_tree() else {
1046        return false;
1047    };
1048    let path = work_tree.join(repo_rel_path);
1049    path.metadata().map(|m| m.is_dir()).unwrap_or(false)
1050}
1051
1052impl Repository {
1053    /// Check whether paths are ignored, reusing one [`IgnoreMatcher`] for the whole batch.
1054    ///
1055    /// Tracked index entries are never reported as ignored. Each result includes verbose-style
1056    /// match metadata compatible with `git check-ignore -v`.
1057    ///
1058    /// # Parameters
1059    ///
1060    /// - `paths` - repository-relative paths with `/` separators (as returned by
1061    ///   [`normalize_repo_relative`]).
1062    /// - `index` - current index; tracked paths are treated as not ignored.
1063    ///
1064    /// # Errors
1065    ///
1066    /// Returns [`Error::Io`] when loading exclude files fails.
1067    pub fn ignore_check(&self, paths: &[String], index: &Index) -> Result<Vec<IgnoreCheck>> {
1068        let mut matcher = IgnoreMatcher::from_repository(self)?;
1069        let mut out = Vec::with_capacity(paths.len());
1070        for path in paths {
1071            let is_dir = path_query_is_dir(self, path);
1072            let (ignored, matched) = matcher.check_path(self, Some(index), path, is_dir)?;
1073            out.push(IgnoreCheck {
1074                path: path.clone(),
1075                ignored,
1076                matched,
1077            });
1078        }
1079        Ok(out)
1080    }
1081}
1082
1083/// Outcome of reading a per-directory pattern file that Git refuses to follow as a symlink.
1084enum InTreePatternFile {
1085    Missing,
1086    Symlink,
1087    Content(String),
1088}
1089
1090/// Read `path` without following a final symlink, so a link (dangling, or to a FIFO or
1091/// device) is reported instead of opened. A missing file costs a single failed open on Unix.
1092fn read_in_tree_pattern_file(path: &Path) -> Result<InTreePatternFile> {
1093    #[cfg(unix)]
1094    let opened = {
1095        use std::os::unix::fs::OpenOptionsExt;
1096        fs::OpenOptions::new()
1097            .read(true)
1098            .custom_flags(libc::O_NOFOLLOW)
1099            .open(path)
1100    };
1101    #[cfg(not(unix))]
1102    let opened = match fs::symlink_metadata(path) {
1103        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1104            return Ok(InTreePatternFile::Missing);
1105        }
1106        Err(err) => return Err(Error::Io(err)),
1107        Ok(meta) if meta.file_type().is_symlink() => return Ok(InTreePatternFile::Symlink),
1108        Ok(_) => fs::File::open(path),
1109    };
1110    let mut file = match opened {
1111        Ok(file) => file,
1112        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1113            return Ok(InTreePatternFile::Missing);
1114        }
1115        Err(_) if fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) => {
1116            return Ok(InTreePatternFile::Symlink);
1117        }
1118        Err(err) => return Err(Error::Io(err)),
1119    };
1120    let mut content = String::new();
1121    std::io::Read::read_to_string(&mut file, &mut content).map_err(Error::Io)?;
1122    Ok(InTreePatternFile::Content(content))
1123}
1124
1125fn read_optional_text(path: &Path) -> Result<Option<String>> {
1126    match fs::read_to_string(path) {
1127        Ok(content) => Ok(Some(content)),
1128        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1129        Err(err) => Err(Error::Io(err)),
1130    }
1131}
1132
1133/// Path of `path` relative to the `.gitignore` directory `base`, or `None` when `path` is not
1134/// inside it. With `ignore_case`, the base compares without regard to ASCII case.
1135fn strip_base<'a>(base: &str, path: &'a str, ignore_case: bool) -> Option<&'a str> {
1136    if base.is_empty() {
1137        return Some(path);
1138    }
1139    if !starts_with_fold(path, base, ignore_case) {
1140        return None;
1141    }
1142    match path.as_bytes().get(base.len()) {
1143        None => Some(""),
1144        // `base.len()` is a char boundary: the byte there is ASCII `/`.
1145        Some(b'/') => path.get(base.len() + 1..),
1146        Some(_) => None,
1147    }
1148}
1149
1150fn eq_fold(text: &str, pattern: &str, ignore_case: bool) -> bool {
1151    if ignore_case {
1152        text.eq_ignore_ascii_case(pattern)
1153    } else {
1154        text == pattern
1155    }
1156}
1157
1158fn starts_with_fold(text: &str, prefix: &str, ignore_case: bool) -> bool {
1159    if !ignore_case {
1160        return text.starts_with(prefix);
1161    }
1162    text.as_bytes()
1163        .get(..prefix.len())
1164        .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_bytes()))
1165}
1166
1167fn ends_with_fold(text: &str, suffix: &str, ignore_case: bool) -> bool {
1168    if !ignore_case {
1169        return text.ends_with(suffix);
1170    }
1171    text.len()
1172        .checked_sub(suffix.len())
1173        .and_then(|start| text.as_bytes().get(start..))
1174        .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix.as_bytes()))
1175}
1176
1177fn parent_dir(path: &str) -> &str {
1178    match path.rsplit_once('/') {
1179        Some((parent, _)) => parent,
1180        None => "",
1181    }
1182}
1183
1184fn pattern_matches_kind(
1185    kind: &IgnorePatternKind,
1186    pattern: &str,
1187    text: &str,
1188    pathname: bool,
1189    ignore_case: bool,
1190) -> bool {
1191    match kind {
1192        IgnorePatternKind::Literal(lit) => eq_fold(text, lit, ignore_case),
1193        IgnorePatternKind::Prefix(prefix) => starts_with_fold(text, prefix, ignore_case),
1194        IgnorePatternKind::Suffix(suffix) => ends_with_fold(text, suffix, ignore_case),
1195        IgnorePatternKind::Wild => {
1196            let mut flags = if pathname { WM_PATHNAME } else { 0 };
1197            if ignore_case {
1198                flags |= WM_CASEFOLD;
1199            }
1200            wildmatch(pattern.as_bytes(), text.as_bytes(), flags)
1201        }
1202    }
1203}
1204
1205fn rule_body_matches(rule: &IgnoreRule, text: &str, pathname: bool, ignore_case: bool) -> bool {
1206    pattern_matches_kind(&rule.match_kind, &rule.body, text, pathname, ignore_case)
1207}
1208
1209/// One line from a sparse-checkout specification blob (`--filter=sparse:oid=…`).
1210#[derive(Debug, Clone)]
1211struct SparsePattern {
1212    negative: bool,
1213    directory_only: bool,
1214    anchored: bool,
1215    has_slash: bool,
1216    body: String,
1217}
1218
1219impl SparsePattern {
1220    fn from_line(line: &str) -> Option<Self> {
1221        let mut raw_line = line.strip_suffix('\r').unwrap_or(line).to_owned();
1222        trim_trailing_spaces_git(&mut raw_line);
1223        if raw_line.is_empty() || raw_line.starts_with('#') {
1224            return None;
1225        }
1226
1227        let mut raw = raw_line;
1228
1229        let mut negative = false;
1230        if let Some(rest) = raw.strip_prefix('!') {
1231            negative = true;
1232            raw = rest.to_owned();
1233        }
1234        if raw.is_empty() {
1235            return None;
1236        }
1237
1238        // Git `parse_path_pattern`: trailing `/` sets `PATTERN_FLAG_MUSTBEDIR` and shortens the
1239        // active pattern length without turning `/*` into an empty body (that would drop the rule).
1240        let mut directory_only = false;
1241        if raw.len() > 1 && raw.ends_with('/') {
1242            directory_only = true;
1243            raw.pop();
1244        }
1245        if raw.is_empty() {
1246            return None;
1247        }
1248
1249        let mut anchored = false;
1250        if let Some(rest) = raw.strip_prefix('/') {
1251            anchored = true;
1252            raw = rest.to_owned();
1253        }
1254        // After `/*` → pop `/` we get `"/"` then strip leading `/` → empty. Git keeps this as the
1255        // root glob `*` (include all top-level names in non-cone sparse files).
1256        if raw.is_empty() && anchored && directory_only {
1257            raw = "*".to_owned();
1258            directory_only = false;
1259        } else if raw.is_empty() {
1260            return None;
1261        }
1262
1263        let has_slash = raw.contains('/');
1264        Some(Self {
1265            negative,
1266            directory_only,
1267            anchored,
1268            has_slash,
1269            body: raw,
1270        })
1271    }
1272}
1273
1274/// Whether `p` matches `pathname` for sparse-checkout evaluation.
1275///
1276/// `as_directory` mirrors Git's `dtype == DT_DIR` pass when walking parent paths:
1277/// patterns with a trailing `/` in the sparse file (`PATTERN_FLAG_MUSTBEDIR`) only
1278/// participate in those iterations.
1279fn sparse_pattern_matches(p: &SparsePattern, pathname: &str, as_directory: bool) -> bool {
1280    // Git never gates basename (NODIR) or anchored `/*` patterns on dtype: `match_basename` /
1281    // `match_pathname` run regardless of `DT_DIR` (dir.c). Earlier code rejected `nodir`
1282    // patterns on the parent-directory pass, which wrongly excluded `subsub/added` under
1283    // `/*` + `!sub` + `sub/added` (t1011 subtest 9). The `!/*/` directory-only rule still
1284    // shadows `/*` for nested dirs via reverse-order first-match (t7817/t1091 preserved).
1285    if p.directory_only && !as_directory {
1286        return false;
1287    }
1288    // On-disk `!/*/` parses as directory-only anchored `*`. For regular files we exclude nested
1289    // paths only (`dir/c`, not `a`). When walking parents (`as_directory`), Git still matches this
1290    // pattern against directory paths like `dir` so `dir/c` can be excluded (t7817).
1291    if p.directory_only && p.anchored && !p.has_slash && p.body == "*" {
1292        // Git `match_pathname` for MUSTBEDIR `/*` / `!/*/`: only top-level directory names
1293        // (no `/` in the pathname), including on the parent-directory pass — not every
1294        // `as_directory` path (dir.c / filter_sparse).
1295        let trimmed = pathname.trim_end_matches('/');
1296        return !trimmed.contains('/');
1297    }
1298    if !p.has_slash && !p.anchored {
1299        sparse_unanchored_basename_matches(&p.body, pathname)
1300    } else if p.anchored && !p.has_slash && p.body == "*" && !p.directory_only {
1301        // Sparse line `/*`: include only top-level paths (one segment). `WM_PATHNAME` keeps `*`
1302        // from matching `/`, but we match against the bare pathname; require no `/` so `dir/c` is
1303        // not included by `/*` alone (t7817 — excluded via `!/*/` on the parent directory pass).
1304        !pathname.contains('/')
1305    } else {
1306        wildmatch(p.body.as_bytes(), pathname.as_bytes(), WM_PATHNAME)
1307    }
1308}
1309
1310fn sparse_pattern_matches_path(p: &SparsePattern, pathname: &str) -> bool {
1311    sparse_pattern_matches(p, pathname, false)
1312}
1313
1314/// Parse sparse-checkout lines from a blob (same syntax as `info/sparse-checkout`).
1315#[must_use]
1316pub fn parse_sparse_patterns_from_blob(content: &str) -> Vec<String> {
1317    let mut patterns = Vec::new();
1318    for line in content.lines() {
1319        let t = line.trim_end_matches('\r');
1320        if t.is_empty() || t.starts_with('#') {
1321            continue;
1322        }
1323        patterns.push(t.to_owned());
1324    }
1325    patterns
1326}
1327
1328fn sparse_list_last_match(
1329    pathname: &str,
1330    as_directory: bool,
1331    parsed: &[SparsePattern],
1332) -> Option<bool> {
1333    for p in parsed.iter().rev() {
1334        if sparse_pattern_matches(p, pathname, as_directory) {
1335            return Some(!p.negative);
1336        }
1337    }
1338    None
1339}
1340
1341/// Non-cone sparse-checkout inclusion, matching Git's `path_in_sparse_checkout`.
1342///
1343/// Walks from the full path toward parents (as in `dir.c:path_in_sparse_checkout_1`):
1344/// each step uses last-match-wins over patterns; `UNDECIDED` falls back to the parent
1345/// directory until the decision is made or the path is rejected at the top level.
1346#[must_use]
1347pub fn path_in_sparse_checkout(path: &str, lines: &[String], work_tree: Option<&Path>) -> bool {
1348    ParsedSparsePatterns::parse(lines).includes(path, work_tree)
1349}
1350
1351/// Non-cone sparse-checkout lines parsed once, for repeated [`path_in_sparse_checkout`] checks.
1352#[derive(Debug, Clone)]
1353pub(crate) struct ParsedSparsePatterns(Vec<SparsePattern>);
1354
1355impl ParsedSparsePatterns {
1356    /// Parse sparse-checkout lines; blank lines and comments are dropped.
1357    pub(crate) fn parse(lines: &[String]) -> Self {
1358        Self(
1359            lines
1360                .iter()
1361                .filter_map(|l| SparsePattern::from_line(l))
1362                .collect(),
1363        )
1364    }
1365
1366    /// Whether `path` is included; `work_tree` lets on-disk directories match directory-only rules.
1367    pub(crate) fn includes(&self, path: &str, work_tree: Option<&Path>) -> bool {
1368        if path.is_empty() || self.0.is_empty() {
1369            return true;
1370        }
1371
1372        let mut end = path.len();
1373        let mut as_directory = false;
1374
1375        loop {
1376            let pathname = &path[..end];
1377            let dtype_is_dir =
1378                work_tree.is_some_and(|wt| wt.join(pathname).is_dir()) || as_directory;
1379
1380            match sparse_list_last_match(pathname, dtype_is_dir, &self.0) {
1381                Some(true) => return true,
1382                Some(false) => return false,
1383                None => {
1384                    let Some(slash) = path[..end].rfind('/') else {
1385                        // Top-level path with no matching rule: Git stops here (UNDECIDED → excluded),
1386                        // not at an empty pathname.
1387                        return false;
1388                    };
1389                    end = slash;
1390                    as_directory = true;
1391                }
1392            }
1393        }
1394    }
1395}
1396
1397/// Last-match-wins sparse semantics for `rev-list --filter=sparse:oid=…` (non-cone).
1398///
1399/// Unanchored patterns without `/` use Git-style basename rules: `pat` matches `pat`,
1400/// `pat.ext`, and `pat/…` paths. Anchored patterns and patterns containing `/` use
1401/// pathname wildmatch with `WM_PATHNAME`.
1402///
1403/// Returns `None` if no pattern matched (`UNDECIDED`).
1404#[must_use]
1405pub fn path_matches_sparse_pattern_list(pathname: &str, lines: &[String]) -> Option<bool> {
1406    let parsed: Vec<SparsePattern> = lines
1407        .iter()
1408        .filter_map(|l| SparsePattern::from_line(l))
1409        .collect();
1410    if parsed.is_empty() {
1411        return None;
1412    }
1413    for p in parsed.iter().rev() {
1414        if sparse_pattern_matches_path(p, pathname) {
1415            return Some(!p.negative);
1416        }
1417    }
1418    None
1419}
1420
1421fn sparse_unanchored_basename_matches(pat: &str, path: &str) -> bool {
1422    let basename = path.rsplit('/').next().unwrap_or(path);
1423    wildmatch(pat.as_bytes(), basename.as_bytes(), 0)
1424}
1425
1426/// Returns the submodule path if `repo_rel_path` names something inside a gitlink entry.
1427#[must_use]
1428pub fn submodule_containing_path(repo_rel_path: &str, index: &Index) -> Option<String> {
1429    let bytes = repo_rel_path.as_bytes();
1430    if bytes.is_empty() {
1431        return None;
1432    }
1433    let mut best: Option<String> = None;
1434    let mut end = 0usize;
1435    while end < bytes.len() {
1436        let Some(slash) = bytes[end..].iter().position(|&b| b == b'/') else {
1437            break;
1438        };
1439        end += slash;
1440        if let Some(entry) = index.get(&bytes[..end], 0) {
1441            if entry.mode == MODE_GITLINK {
1442                best = Some(String::from_utf8_lossy(&bytes[..end]).into_owned());
1443            }
1444        }
1445        end += 1;
1446    }
1447    best
1448}
1449
1450/// Convert a user-supplied path into a normalized repository-relative path.
1451///
1452/// # Parameters
1453///
1454/// - `repo` - repository handle.
1455/// - `cwd` - current working directory.
1456/// - `path` - user input path string.
1457///
1458/// # Errors
1459///
1460/// Returns [`Error::PathError`] if the path resolves outside the work tree.
1461pub fn normalize_repo_relative(repo: &Repository, cwd: &Path, path: &str) -> Result<String> {
1462    let Some(work_tree) = &repo.work_tree() else {
1463        return Err(Error::PathError(
1464            "this operation must be run in a work tree".to_owned(),
1465        ));
1466    };
1467    if path.starts_with(':') {
1468        return Ok(path.to_owned());
1469    }
1470    let query_as_dir = path.ends_with('/');
1471    let input = Path::new(path);
1472    let combined = if input.is_absolute() {
1473        input.to_path_buf()
1474    } else {
1475        cwd.join(input)
1476    };
1477    let normalized = normalize_path(&combined);
1478    // Repository opening already resolves the worktree root. Most queries share that
1479    // spelling, so avoid canonicalizing the same root again for every path in a batch.
1480    // Keep the filesystem-alias fallback for spellings such as macOS /tmp and /private/tmp.
1481    let mut rel = match normalized.strip_prefix(work_tree) {
1482        Ok(relative) => crate::git_path::path_to_git_slash(relative),
1483        Err(_) => {
1484            crate::git_path::strip_worktree_prefix(&normalized, work_tree).ok_or_else(|| {
1485                Error::PathError(format!("path '{path}' is outside repository work tree"))
1486            })?
1487        }
1488    };
1489    if query_as_dir && !rel.is_empty() && !rel.ends_with('/') {
1490        rel.push('/');
1491    }
1492    Ok(rel)
1493}
1494
1495fn normalize_path(path: &Path) -> PathBuf {
1496    let mut out = PathBuf::new();
1497    for component in path.components() {
1498        match component {
1499            Component::CurDir => {}
1500            Component::ParentDir => {
1501                out.pop();
1502            }
1503            other => out.push(other.as_os_str()),
1504        }
1505    }
1506    out
1507}
1508
1509#[cfg(test)]
1510mod normalization_tests {
1511    use super::*;
1512
1513    #[test]
1514    fn bare_repository_queries_return_a_path_error() {
1515        let dir = tempfile::tempdir().unwrap();
1516        let repo = crate::repo::init_repository(
1517            dir.path(),
1518            true,
1519            "main",
1520            None,
1521            crate::RefStorageFormat::Files,
1522        )
1523        .unwrap();
1524        assert!(matches!(
1525            normalize_repo_relative(&repo, dir.path(), "missing/file"),
1526            Err(Error::PathError(_))
1527        ));
1528    }
1529}
1530
1531#[cfg(test)]
1532mod per_directory_load_tests {
1533    use super::*;
1534
1535    #[test]
1536    fn known_directory_reads_its_exclude_file_on_first_query() {
1537        let dir = tempfile::tempdir().unwrap();
1538        let root = dir.path();
1539        let repo =
1540            crate::repo::init_repository(root, false, "main", None, crate::RefStorageFormat::Files)
1541                .unwrap();
1542        fs::create_dir_all(root.join("d/sub")).unwrap();
1543        fs::write(root.join("d/.gitignore"), "*.tmp\n").unwrap();
1544
1545        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
1546        matcher.note_directory_known("d");
1547        matcher.note_directory_known("d/sub");
1548        assert!(
1549            matcher.gitignore_cache.is_empty(),
1550            "entering a directory must not read its exclude file"
1551        );
1552        assert!(matcher.known_worktree_directories.contains("d/sub"));
1553
1554        assert!(matcher
1555            .is_path_ignored(&repo, None, "d/sub/x.tmp", false)
1556            .unwrap());
1557        assert!(!matcher
1558            .is_path_ignored(&repo, None, "d/sub/x.txt", false)
1559            .unwrap());
1560        assert!(matcher.gitignore_cache.contains_key("d"));
1561        assert!(matcher.gitignore_cache.contains_key("d/sub"));
1562    }
1563}
1564
1565#[cfg(test)]
1566mod sparse_checkout_tests {
1567    use super::*;
1568
1569    #[test]
1570    fn non_cone_default_init_patterns() {
1571        let lines = vec!["/*".into(), "!/*/".into()];
1572        assert!(path_in_sparse_checkout("a", &lines, None));
1573        assert!(!path_in_sparse_checkout("folder1/a", &lines, None));
1574        let wt = std::env::temp_dir().join("grit-sparse-wt-test");
1575        let _ = std::fs::create_dir_all(wt.join("folder1"));
1576        let _ = std::fs::write(wt.join("a"), b"x");
1577        assert!(!path_in_sparse_checkout("folder1/a", &lines, Some(&wt)));
1578        assert!(!path_in_sparse_checkout("folder1", &lines, Some(&wt)));
1579    }
1580
1581    #[test]
1582    fn unanchored_glob_matches_path_components() {
1583        let lines = vec!["/*".into(), "!/*/".into(), "*folder*".into()];
1584        assert!(path_in_sparse_checkout("folder1/a", &lines, None));
1585        assert!(path_in_sparse_checkout("folder2/a", &lines, None));
1586        assert!(!path_in_sparse_checkout("deep/a", &lines, None));
1587    }
1588
1589    #[test]
1590    fn non_cone_src_nested_under_included_dir_with_negated_slash_slash() {
1591        let lines = vec!["/*".into(), "!/*/".into(), "/src/".into()];
1592        assert!(path_in_sparse_checkout("a.txt", &lines, None));
1593        assert!(path_in_sparse_checkout("src/z", &lines, None));
1594        assert!(path_in_sparse_checkout("src/deep/w", &lines, None));
1595        assert!(!path_in_sparse_checkout("d1/x", &lines, None));
1596        assert!(!path_in_sparse_checkout("d1/sub/y", &lines, None));
1597    }
1598
1599    #[test]
1600    fn unanchored_star_with_negated_other_includes_other_slash_q() {
1601        let lines = vec!["*".into(), "!other".into()];
1602        assert!(path_in_sparse_checkout("other/q", &lines, None));
1603        assert!(!path_in_sparse_checkout("other", &lines, None));
1604    }
1605}
1606
1607#[cfg(test)]
1608mod gitignore_glob_tests {
1609    use super::*;
1610
1611    fn pathname_pattern_matches(pattern: &str, text: &str) -> bool {
1612        pattern_matches_kind(
1613            &classify_ignore_pattern(pattern),
1614            pattern,
1615            text,
1616            true,
1617            false,
1618        )
1619    }
1620
1621    fn rule(line: &str, base_dir: &str) -> IgnoreRule {
1622        parse_rule_line(line, 1, IgnoreSource::InfoExclude, base_dir).unwrap()
1623    }
1624
1625    #[test]
1626    fn lines_lose_at_most_one_trailing_cr() {
1627        let rules = |content: &str| {
1628            parse_gitignore_content(content, IgnoreSource::InfoExclude, "", &mut Vec::new())
1629                .unwrap()
1630        };
1631        let [crlf] = rules("a\r\n").try_into().unwrap();
1632        assert!(rule_matches(&crlf, "a", false, false));
1633        let [double_cr] = rules("a\r\r\n").try_into().unwrap();
1634        assert!(rule_matches(&double_cr, "a\r", false, false));
1635        assert!(!rule_matches(&double_cr, "a", false, false));
1636        let [leading_cr] = rules("\ra\n").try_into().unwrap();
1637        assert!(rule_matches(&leading_cr, "\ra", false, false));
1638        assert!(rules("a\nb").len() == 2);
1639    }
1640
1641    #[test]
1642    fn every_pattern_kind_folds_ascii_case_only_with_ignore_case() {
1643        for (pattern, kind, path) in [
1644            ("README", "literal", "readme"),
1645            ("Target*", "prefix", "target-debug"),
1646            ("*.LOG", "suffix", "debug.log"),
1647            ("[Dd]EBUG?.txt", "wild", "debug1.TXT"),
1648        ] {
1649            let r = rule(pattern, "");
1650            assert!(!rule_matches(&r, path, false, false), "{kind}: {pattern}");
1651            assert!(rule_matches(&r, path, false, true), "{kind}: {pattern}");
1652            assert!(rule_matches(&r, &format!("sub/{path}"), false, true));
1653        }
1654        assert!(!rule_matches(&rule("*.LOG", ""), "x.lag", false, true));
1655        assert!(!rule_matches(&rule("Target*", ""), "tar", false, true));
1656        assert!(!rule_matches(&rule("*.LOG", ""), "LOG", false, true));
1657        // Git folds ASCII only; non-ASCII letters keep their case.
1658        assert!(!rule_matches(&rule("*É", ""), "é", false, true));
1659        assert!(!rule_matches(&rule("Über", ""), "über", false, true));
1660        assert!(rule_matches(&rule("Über", ""), "ÜBER", false, true));
1661    }
1662
1663    #[test]
1664    fn directory_and_anchored_rules_fold_case() {
1665        let dir_only = rule("Build/", "");
1666        assert!(rule_matches(&dir_only, "build", true, true));
1667        assert!(rule_matches(&dir_only, "src/BUILD", true, true));
1668        assert!(!rule_matches(&dir_only, "build", true, false));
1669        assert!(!rule_matches(&dir_only, "build", false, true));
1670        let anchored = rule("/Docs/*.PDF", "");
1671        assert!(rule_matches(&anchored, "docs/manual.pdf", false, true));
1672        assert!(!rule_matches(&anchored, "docs/sub/manual.pdf", false, true));
1673        assert!(!rule_matches(&anchored, "docs/manual.pdf", false, false));
1674    }
1675
1676    #[test]
1677    fn nested_gitignore_base_dir_folds_case() {
1678        let nested = rule("*.tmp", "Sub/Dir");
1679        assert!(rule_matches(&nested, "Sub/Dir/a.tmp", false, false));
1680        assert!(!rule_matches(&nested, "sub/dir/a.tmp", false, false));
1681        assert!(rule_matches(&nested, "sub/dir/a.tmp", false, true));
1682        assert!(!rule_matches(&nested, "sub/dirx/a.tmp", false, true));
1683        assert!(!rule_matches(&nested, "sub/di", false, true));
1684        assert_eq!(strip_base("Sub", "sub", true), Some(""));
1685        assert_eq!(strip_base("Sub", "SUB/x", true), Some("x"));
1686        assert_eq!(strip_base("Sub", "subway/x", true), None);
1687        assert_eq!(strip_base("Sub", "sub/x", false), None);
1688    }
1689
1690    #[test]
1691    fn precompiled_literal_prefix_suffix_patterns() {
1692        assert!(matches!(
1693            classify_ignore_pattern("build"),
1694            IgnorePatternKind::Literal(_)
1695        ));
1696        assert!(matches!(
1697            classify_ignore_pattern("target*"),
1698            IgnorePatternKind::Prefix(_)
1699        ));
1700        assert!(matches!(
1701            classify_ignore_pattern("*.o"),
1702            IgnorePatternKind::Suffix(_)
1703        ));
1704        assert!(matches!(
1705            classify_ignore_pattern("a*b"),
1706            IgnorePatternKind::Wild
1707        ));
1708        assert!(matches!(
1709            classify_ignore_pattern(r"foo\ bar"),
1710            IgnorePatternKind::Wild
1711        ));
1712        assert!(matches!(
1713            classify_ignore_pattern("foo/*"),
1714            IgnorePatternKind::Wild
1715        ));
1716        assert!(!pathname_pattern_matches("foo/*", "foo/bar/baz"));
1717        assert!(rule_body_matches(
1718            &IgnoreRule {
1719                source: IgnoreSource::InfoExclude,
1720                line_number: 1,
1721                pattern_text: "*.log".into(),
1722                negative: false,
1723                directory_only: false,
1724                anchored: false,
1725                has_slash: false,
1726                body: "*.log".into(),
1727                base_dir: String::new(),
1728                match_kind: IgnorePatternKind::Suffix(".log".into()),
1729            },
1730            "app.log",
1731            false,
1732            false
1733        ));
1734    }
1735
1736    #[test]
1737    fn dir_star_extension_matches_nested_path() {
1738        // Slash in pattern → WM_PATHNAME: `*` does not cross `/` (gitignore(5)).
1739        assert!(!pathname_pattern_matches("doc/*.pdf", "doc/sub/manual.pdf"));
1740        assert!(pathname_pattern_matches("doc/*.pdf", "doc/manual.pdf"));
1741        assert!(!pathname_pattern_matches("doc/*.pdf", "other/manual.pdf"));
1742        assert!(pathname_pattern_matches(
1743            "doc/**/*.pdf",
1744            "doc/sub/manual.pdf"
1745        ));
1746    }
1747
1748    #[test]
1749    fn dir_star_extension_unexpanded_when_parent_has_glob() {
1750        assert!(!pathname_pattern_matches("*/foo/*.pdf", "a/foo/sub/x.pdf"));
1751    }
1752
1753    #[test]
1754    fn nested_dir_star_extension() {
1755        assert!(!pathname_pattern_matches("foo/bar/*.c", "foo/bar/baz/x.c"));
1756    }
1757}
1758
1759#[cfg(all(test, unix))]
1760mod per_directory_gitignore_tests {
1761    use super::*;
1762    use crate::repo::init_repository;
1763    use crate::RefStorageFormat;
1764    use std::os::unix::ffi::OsStringExt;
1765    use std::os::unix::fs::symlink;
1766    use tempfile::TempDir;
1767
1768    fn ignored_with_warnings(repo: &Repository, path: &str) -> (bool, Vec<String>) {
1769        let mut matcher = IgnoreMatcher::from_repository(repo).unwrap();
1770        let ignored = matcher.is_path_ignored(repo, None, path, false).unwrap();
1771        (ignored, matcher.take_warnings())
1772    }
1773
1774    #[test]
1775    fn symlinked_gitignore_is_skipped_with_warning_and_plain_one_applies() {
1776        let dir = TempDir::new().unwrap();
1777        let root = dir.path();
1778        let repo = init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
1779        fs::create_dir_all(root.join("plain")).unwrap();
1780        fs::create_dir_all(root.join("to_file")).unwrap();
1781        fs::create_dir_all(root.join("to_dir")).unwrap();
1782        fs::create_dir_all(root.join("none")).unwrap();
1783        fs::write(root.join("plain/.gitignore"), "*.log\n").unwrap();
1784        fs::write(root.join("rules"), "*.log\n").unwrap();
1785        symlink(root.join("rules"), root.join("to_file/.gitignore")).unwrap();
1786        symlink(root.join("plain"), root.join("to_dir/.gitignore")).unwrap();
1787        fs::create_dir_all(root.join("dangling")).unwrap();
1788        symlink(root.join("no-such-rules"), root.join("dangling/.gitignore")).unwrap();
1789        fs::create_dir_all(root.join("to_fifo")).unwrap();
1790        let fifo = std::ffi::CString::new(root.join("fifo").into_os_string().into_vec()).unwrap();
1791        // SAFETY: `fifo` is a NUL-terminated path that outlives the call.
1792        assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o644) }, 0);
1793        symlink(root.join("fifo"), root.join("to_fifo/.gitignore")).unwrap();
1794
1795        assert_eq!(ignored_with_warnings(&repo, "plain/a.log"), (true, vec![]));
1796        assert_eq!(ignored_with_warnings(&repo, "none/a.log"), (false, vec![]));
1797        let (done_tx, done_rx) = std::sync::mpsc::channel();
1798        let fifo_root = root.to_path_buf();
1799        std::thread::spawn(move || {
1800            let options = crate::environment::RepositoryOptions::empty().with_work_tree(&fifo_root);
1801            let repo = Repository::open(&fifo_root.join(".git"), &options).unwrap();
1802            let _ = done_tx.send(ignored_with_warnings(&repo, "to_fifo/a.log"));
1803        });
1804        let (ignored, warnings) = done_rx
1805            .recv_timeout(std::time::Duration::from_secs(5))
1806            .expect("a .gitignore symlink to a FIFO must be rejected without opening it");
1807        assert!(!ignored);
1808        assert_eq!(warnings.len(), 1, "{warnings:?}");
1809        for dir in ["to_file", "to_dir", "dangling"] {
1810            let (ignored, warnings) = ignored_with_warnings(&repo, &format!("{dir}/a.log"));
1811            assert!(!ignored, "{dir}: symlinked .gitignore must not apply");
1812            assert_eq!(warnings.len(), 1, "{dir}: {warnings:?}");
1813            assert!(warnings[0].contains(&format!("{dir}/.gitignore")));
1814        }
1815    }
1816}
1817
1818#[cfg(test)]
1819mod ignore_case_matcher_tests {
1820    use super::*;
1821    use crate::repo::init_repository;
1822    use crate::RefStorageFormat;
1823    use tempfile::TempDir;
1824
1825    fn issue_repo(ignore_case: bool) -> (TempDir, Repository) {
1826        let dir = TempDir::new().unwrap();
1827        let root = dir.path();
1828        init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
1829        let mut config = fs::read_to_string(root.join(".git/config")).unwrap();
1830        config.push_str(&format!("[core]\n\tignorecase = {ignore_case}\n"));
1831        fs::write(root.join(".git/config"), config).unwrap();
1832        fs::write(root.join(".gitignore"), "Build/\n*.LOG\n!keep.log\n").unwrap();
1833        fs::create_dir_all(root.join("build")).unwrap();
1834        for file in ["build/x.o", "debug.log", "keep.log", "README"] {
1835            fs::write(root.join(file), "x\n").unwrap();
1836        }
1837        let options = crate::environment::RepositoryOptions::empty().with_work_tree(root);
1838        let repo = Repository::open(&root.join(".git"), &options).unwrap();
1839        (dir, repo)
1840    }
1841
1842    fn ignored(matcher: &mut IgnoreMatcher, repo: &Repository, path: &str, is_dir: bool) -> bool {
1843        let fast = matcher.is_path_ignored(repo, None, path, is_dir).unwrap();
1844        let (verbose, _) = matcher.check_path(repo, None, path, is_dir).unwrap();
1845        assert_eq!(fast, verbose, "{path}");
1846        fast
1847    }
1848
1849    #[test]
1850    fn from_repository_honors_core_ignorecase() {
1851        let (_dir, repo) = issue_repo(true);
1852        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
1853        assert!(matcher.ignore_case());
1854        assert!(ignored(&mut matcher, &repo, "build", true));
1855        assert!(ignored(&mut matcher, &repo, "build/x.o", false));
1856        assert!(ignored(&mut matcher, &repo, "debug.log", false));
1857        assert!(!ignored(&mut matcher, &repo, "keep.log", false));
1858        assert!(!ignored(&mut matcher, &repo, "KEEP.LOG", false));
1859        assert!(!ignored(&mut matcher, &repo, "README", false));
1860        let (_, matched) = matcher.check_path(&repo, None, "build/x.o", false).unwrap();
1861        assert_eq!(matched.unwrap().pattern_text, "Build/");
1862    }
1863
1864    #[test]
1865    fn case_sensitive_by_default_and_overridable() {
1866        let (_dir, repo) = issue_repo(false);
1867        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
1868        assert!(!matcher.ignore_case());
1869        assert!(!ignored(&mut matcher, &repo, "build/x.o", false));
1870        assert!(!ignored(&mut matcher, &repo, "debug.log", false));
1871        assert!(ignored(&mut matcher, &repo, "Build/x.o", false));
1872
1873        let mut folded = matcher.with_ignore_case(true);
1874        assert!(ignored(&mut folded, &repo, "build/x.o", false));
1875        assert!(ignored(&mut folded, &repo, "debug.log", false));
1876        assert!(!IgnoreMatcher::default().ignore_case());
1877    }
1878}
1879
1880#[cfg(test)]
1881mod index_lookup_scaling_tests {
1882    use super::*;
1883    use crate::index::{index_path_cmp, Index, IndexEntry, MODE_REGULAR};
1884    use crate::objects::ObjectId;
1885    use crate::repo::init_repository;
1886    use crate::RefStorageFormat;
1887    use tempfile::TempDir;
1888
1889    fn dummy_oid() -> ObjectId {
1890        ObjectId::from_bytes(&[0u8; 20]).unwrap()
1891    }
1892
1893    fn make_entry(path: &str) -> IndexEntry {
1894        IndexEntry {
1895            ctime_sec: 0,
1896            ctime_nsec: 0,
1897            mtime_sec: 0,
1898            mtime_nsec: 0,
1899            dev: 0,
1900            ino: 0,
1901            mode: MODE_REGULAR,
1902            uid: 0,
1903            gid: 0,
1904            size: 0,
1905            oid: dummy_oid(),
1906            flags: path.len().min(0xFFF) as u16,
1907            flags_extended: None,
1908            path: path.as_bytes().to_vec(),
1909            base_index_pos: 0,
1910        }
1911    }
1912
1913    #[test]
1914    fn tracked_check_path_index_lookups_sublinear_in_index_size() {
1915        const N: usize = 100_000;
1916        const M: usize = 1_000;
1917        let dir = TempDir::new().unwrap();
1918        let repo =
1919            init_repository(dir.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1920        let mut index = Index::new();
1921        for i in 0..N {
1922            index.add_or_replace(make_entry(&format!("tracked/{i:06}.txt")));
1923        }
1924        let mut matcher = IgnoreMatcher::default();
1925        index_path_cmp::reset();
1926        for i in 0..M {
1927            let path = format!("untracked/{i:06}.txt");
1928            let (ignored, _) = matcher
1929                .check_path(&repo, Some(&index), &path, false)
1930                .unwrap();
1931            assert!(!ignored, "untracked path must not be ignored");
1932        }
1933        let cmps = index_path_cmp::take();
1934        let log_n = (N as f64).log2().ceil() as usize;
1935        let bound = 100 * M * log_n;
1936        assert!(
1937            cmps < bound,
1938            "index path comparisons {cmps} should stay under {bound} (N={N}, M={M})"
1939        );
1940    }
1941}
1942
1943#[cfg(test)]
1944mod pattern_list_tests {
1945    use super::IgnorePatternList;
1946
1947    fn ignored(content: &str, path: &str, is_dir: bool) -> bool {
1948        IgnorePatternList::parse(content.as_bytes(), "")
1949            .is_ignored(path, is_dir, false)
1950            .unwrap_or(false)
1951    }
1952
1953    #[test]
1954    fn basename_patterns_match_last_component_only() {
1955        assert!(ignored("foo\n", "foo", false));
1956        assert!(ignored("foo\n", "dir/foo", false));
1957        assert!(!ignored("foo\n", "foo/bar", false));
1958        assert!(ignored("*.o\n", "a/b/c.o", false));
1959    }
1960
1961    #[test]
1962    fn slash_patterns_match_whole_path() {
1963        assert!(ignored("/foo\n", "foo", false));
1964        assert!(!ignored("/foo\n", "dir/foo", false));
1965        assert!(ignored("dir/foo\n", "dir/foo", false));
1966        assert!(!ignored("dir/foo\n", "x/dir/foo", false));
1967        assert!(ignored("dir/*.o\n", "dir/a.o", false));
1968        assert!(!ignored("dir/*.o\n", "dir/sub/a.o", false));
1969        assert!(ignored("**/foo\n", "a/b/foo", false));
1970        assert!(ignored("a/**/b\n", "a/x/y/b", false));
1971    }
1972
1973    #[test]
1974    fn directory_only_patterns_need_a_directory() {
1975        assert!(ignored("foo/\n", "foo", true));
1976        assert!(!ignored("foo/\n", "foo", false));
1977        assert!(!ignored("foo/\n", "foo/bar", false));
1978    }
1979
1980    #[test]
1981    fn last_matching_pattern_wins() {
1982        let list = IgnorePatternList::parse(b"*.log\n!keep.log\n", "");
1983        assert_eq!(list.is_ignored("a.log", false, false), Some(true));
1984        assert_eq!(list.is_ignored("keep.log", false, false), Some(false));
1985        assert_eq!(list.is_ignored("a.txt", false, false), None);
1986        let m = list.last_match("keep.log", false, false).unwrap();
1987        assert_eq!((m.line_number, m.pattern_text.as_str()), (2, "!keep.log"));
1988    }
1989
1990    #[test]
1991    fn comments_blank_lines_and_trailing_spaces() {
1992        let list = IgnorePatternList::parse(b"\xef\xbb\xbf# comment\n\nfoo  \nbar\\ \n\\#x\n", "");
1993        assert_eq!(list.len(), 3);
1994        assert!(ignored("foo  \n", "foo", false));
1995        assert!(list.is_ignored("bar ", false, false).unwrap());
1996        assert!(list.is_ignored("#x", false, false).unwrap());
1997    }
1998
1999    #[test]
2000    fn base_directory_scopes_patterns() {
2001        let list = IgnorePatternList::parse(b"foo\n/bar\n", "sub");
2002        assert_eq!(list.is_ignored("sub/foo", false, false), Some(true));
2003        assert_eq!(list.is_ignored("sub/x/foo", false, false), Some(true));
2004        assert_eq!(list.is_ignored("sub/bar", false, false), Some(true));
2005        assert_eq!(list.is_ignored("sub/x/bar", false, false), None);
2006        assert_eq!(list.is_ignored("foo", false, false), None);
2007        assert_eq!(list.is_ignored("subfoo", false, false), None);
2008    }
2009
2010    #[test]
2011    fn ignore_case() {
2012        let list = IgnorePatternList::parse(b"*.LOG\nBuild/\n", "");
2013        assert_eq!(list.is_ignored("debug.log", false, false), None);
2014        assert_eq!(list.is_ignored("debug.log", false, true), Some(true));
2015        assert_eq!(list.is_ignored("build", true, true), Some(true));
2016    }
2017}