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