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