Skip to main content

grit_lib/
ignore.rs

1//! Ignore and exclude matching for `check-ignore`.
2//!
3//! This module implements a focused subset of Git ignore behavior:
4//! per-directory `.gitignore`, `.git/info/exclude`, and `core.excludesfile`
5//! with "last matching pattern wins" precedence. With `core.ignorecase=true`, patterns match
6//! without regard to ASCII case, as in Git.
7
8use std::collections::HashMap;
9use std::fmt;
10use std::fs;
11use std::path::{Component, Path, PathBuf};
12
13use crate::config::parse_path;
14use crate::error::{Error, Result};
15use crate::index::{Index, MODE_GITLINK};
16use crate::objects::ObjectKind;
17use crate::repo::Repository;
18use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
19
20/// Where an ignore pattern was loaded from.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum IgnoreSource {
23    /// A per-directory exclude file (typically `.gitignore`).
24    PerDirectory(String),
25    /// Patterns from `.git/info/exclude`.
26    InfoExclude,
27    /// Patterns from `core.excludesfile` (or the XDG default).
28    CoreExcludesFile(String),
29}
30
31impl fmt::Display for IgnoreSource {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::PerDirectory(path) => f.write_str(path),
35            Self::InfoExclude => f.write_str(".git/info/exclude"),
36            Self::CoreExcludesFile(path) => f.write_str(path),
37        }
38    }
39}
40
41/// Metadata for a matching rule.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct IgnoreMatch {
44    /// Typed source of the matching pattern (see [`IgnoreSource`]).
45    pub source: IgnoreSource,
46    /// Line number in the source file (1-based).
47    pub line_number: usize,
48    /// Pattern text as written (excluding comments/blank lines).
49    pub pattern_text: String,
50    /// Whether this is a negated pattern (`!pattern`).
51    pub negative: bool,
52}
53
54/// Result of checking one path against repository ignore rules.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct IgnoreCheck {
57    /// Repository-relative path that was queried.
58    pub path: String,
59    /// Whether the path is ignored (always `false` for tracked paths).
60    pub ignored: bool,
61    /// Last matching pattern metadata, when Git would attribute one (`check-ignore -v`).
62    pub matched: Option<IgnoreMatch>,
63}
64
65/// Patterns parsed from one exclude file (such as a `.gitignore`), for matching single paths.
66///
67/// Unlike [`IgnoreMatcher`], a list is not tied to a repository: the caller decides which
68/// files apply to a path and in what order, as tools that layer their own ignore handling on
69/// top of Git's file format need to. Matching follows Git's `last_matching_pattern_from_list`
70/// for one path: patterns without a slash match the path's last component, other patterns match
71/// the whole path relative to the list's base directory, and directory-only patterns (`foo/`)
72/// match only when the path is a directory. Parent directories are not consulted; callers that
73/// walk a tree stop descending into ignored directories, as Git does.
74///
75/// # Examples
76///
77/// ```
78/// use grit_lib::worktree::ignore::IgnorePatternList;
79///
80/// let list = IgnorePatternList::parse(b"*.log\n!keep.log\nbuild/\n", "");
81/// assert!(list.is_ignored("debug.log", false, false).unwrap());
82/// assert!(!list.is_ignored("keep.log", false, false).unwrap());
83/// assert!(list.is_ignored("build", true, false).unwrap());
84/// assert_eq!(list.is_ignored("build", false, false), None);
85/// ```
86#[derive(Debug, Clone, Default)]
87pub struct IgnorePatternList {
88    rules: Vec<IgnoreRule>,
89}
90
91/// The pattern that decided an [`IgnorePatternList::last_match`] query.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct IgnorePatternMatch {
94    /// Line number of the pattern in the parsed content (1-based).
95    pub line_number: usize,
96    /// Pattern text as written (without trailing whitespace).
97    pub pattern_text: String,
98    /// Whether this is a negated pattern (`!pattern`), which re-includes the path.
99    pub negative: bool,
100}
101
102impl IgnorePatternList {
103    /// Parse exclude-file `content` whose patterns are relative to `base_dir`.
104    ///
105    /// `base_dir` is the slash-separated directory holding the file, relative to the root
106    /// paths are matched against (`""` for the root). Blank lines and comments are skipped,
107    /// a UTF-8 byte order mark on the first line is dropped, and invalid UTF-8 is replaced
108    /// as in [`String::from_utf8_lossy`].
109    #[must_use]
110    pub fn parse(content: &[u8], base_dir: &str) -> Self {
111        let content = String::from_utf8_lossy(content);
112        let base_dir = base_dir.trim_matches('/');
113        let source = IgnoreSource::PerDirectory(if base_dir.is_empty() {
114            ".gitignore".to_owned()
115        } else {
116            format!("{base_dir}/.gitignore")
117        });
118        let mut warnings = Vec::new();
119        let rules =
120            parse_gitignore_content(&content, source, base_dir, &mut warnings).unwrap_or_default();
121        Self { rules }
122    }
123
124    /// Whether the list has no patterns.
125    #[must_use]
126    pub fn is_empty(&self) -> bool {
127        self.rules.is_empty()
128    }
129
130    /// Number of patterns in the list.
131    #[must_use]
132    pub fn len(&self) -> usize {
133        self.rules.len()
134    }
135
136    /// The last pattern matching `path`, if any.
137    ///
138    /// `path` is slash-separated and relative to the root the list's base directory is
139    /// relative to. Paths outside the base directory never match. With `ignore_case`,
140    /// patterns compare without regard to ASCII case (`core.ignorecase`).
141    #[must_use]
142    pub fn last_match(
143        &self,
144        path: &str,
145        is_dir: bool,
146        ignore_case: bool,
147    ) -> Option<IgnorePatternMatch> {
148        self.rules
149            .iter()
150            .rev()
151            .find(|rule| rule_matches(rule, path, is_dir, ignore_case))
152            .map(|rule| IgnorePatternMatch {
153                line_number: rule.line_number,
154                pattern_text: rule.pattern_text.clone(),
155                negative: rule.negative,
156            })
157    }
158
159    /// Whether the last matching pattern ignores `path` (`Some(false)` when it re-includes
160    /// it), or `None` when no pattern matches. See [`Self::last_match`].
161    #[must_use]
162    pub fn is_ignored(&self, path: &str, is_dir: bool, ignore_case: bool) -> Option<bool> {
163        self.last_match(path, is_dir, ignore_case)
164            .map(|m| !m.negative)
165    }
166}
167
168#[derive(Debug, Clone)]
169enum IgnorePatternKind {
170    Wild,
171    Literal(String),
172    Prefix(String),
173    Suffix(String),
174}
175
176#[derive(Debug, Clone)]
177struct IgnoreRule {
178    source: IgnoreSource,
179    line_number: usize,
180    pattern_text: String,
181    negative: bool,
182    directory_only: bool,
183    anchored: bool,
184    has_slash: bool,
185    body: String,
186    base_dir: String,
187    match_kind: IgnorePatternKind,
188}
189
190/// Patterns of one per-directory exclude file; every rule's `base_dir` is `base_dir`.
191#[derive(Debug, Clone)]
192struct DirRuleSet {
193    base_dir: String,
194    rules: Vec<IgnoreRule>,
195}
196
197/// What the per-directory exclude stack looks like inside one work-tree directory.
198#[derive(Debug, Clone)]
199struct DirState {
200    /// Ids into `IgnoreMatcher::rule_sets` of the exclude files that apply, root first.
201    stack: Vec<usize>,
202    /// Whether deeper directories may still add their own exclude file; `false` once an
203    /// ancestor turned out to be a file (or per-directory files are not read at all).
204    extendable: bool,
205    /// The pattern excluding this directory or one of its ancestors, which excludes everything
206    /// below it (`prep_exclude` stops descending at an excluded directory).
207    excluded: Option<IgnoreMatch>,
208}
209
210impl From<&IgnoreRule> for IgnoreMatch {
211    fn from(rule: &IgnoreRule) -> Self {
212        Self {
213            source: rule.source.clone(),
214            line_number: rule.line_number,
215            pattern_text: rule.pattern_text.clone(),
216            negative: rule.negative,
217        }
218    }
219}
220
221/// Engine used to evaluate ignore patterns against repository-relative paths.
222#[derive(Debug, Default, Clone)]
223pub struct IgnoreMatcher {
224    /// Patterns from `git ls-files -x` / `--exclude` (Git `EXC_CMDL`), evaluated first.
225    cli_rules: Vec<IgnoreRule>,
226    global_rules: Vec<IgnoreRule>,
227    info_rules: Vec<IgnoreRule>,
228    /// Patterns from `git ls-files -X` / `--exclude-from` (Git `EXC_FILE`, after global/info).
229    exclude_from_rules: Vec<IgnoreRule>,
230    /// Per-directory exclude files read so far, keyed by directory, as ids into `rule_sets`.
231    gitignore_cache: HashMap<String, usize>,
232    rule_sets: Vec<DirRuleSet>,
233    /// Per-directory exclude filename (`ls-files --exclude-per-directory`, default `.gitignore`).
234    per_directory_name: Option<String>,
235    /// Whether per-directory exclude files are read at all (Git `EXC_DIRS`).
236    ///
237    /// `from_repository` (standard excludes) and an explicit `--exclude-per-directory`/`--ignored`
238    /// enable this; a matcher built only for `-x`/`-X` does not consult per-directory files.
239    read_per_directory: bool,
240    /// Warnings emitted while loading in-tree `.gitignore` (e.g. symlink paths).
241    pub warnings: Vec<String>,
242    /// Exclude state of each queried parent directory, keyed by directory, as ids into
243    /// `dir_states`. Built once per directory from its parent's state.
244    dir_state_ids: HashMap<String, usize>,
245    dir_states: Vec<DirState>,
246    /// The directory of the previous query, which consecutive sibling paths share.
247    last_dir: Option<(String, usize)>,
248    /// Fold ASCII case when comparing patterns and base directories (`core.ignorecase`).
249    ignore_case: bool,
250    /// Repository-relative directories entered during a depth-first walk (skip restat in EXC_DIRS).
251    known_worktree_directories: std::collections::HashSet<String>,
252}
253
254impl IgnoreMatcher {
255    /// Build a matcher from repository exclude sources.
256    ///
257    /// Honors `core.ignorecase`: when it is `true`, patterns match paths without regard to
258    /// ASCII case (`Build/` ignores `build/`, `*.LOG` ignores `debug.log`), as Git does.
259    ///
260    /// # Parameters
261    ///
262    /// - `repo` - open repository.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`Error::Io`] if configured pattern files cannot be read, or a config error if
267    /// the repository configuration cannot be loaded.
268    pub fn from_repository(repo: &Repository) -> Result<Self> {
269        let ignore_case = crate::crlf::config_ignorecase(repo.config()?.as_ref());
270        Ok(Self {
271            global_rules: load_global_excludes(repo)?,
272            info_rules: load_info_excludes(repo)?,
273            // Standard excludes include the per-directory `.gitignore` set (Git `EXC_DIRS`).
274            read_per_directory: true,
275            ignore_case,
276            ..Self::default()
277        })
278    }
279
280    /// Set whether patterns match case-insensitively (Git's `core.ignorecase`).
281    ///
282    /// [`Self::from_repository`] reads this from configuration; use this to override it, or to
283    /// opt in on a matcher built with [`Default::default`]. Only ASCII letters are folded, like
284    /// Git.
285    ///
286    /// # Parameters
287    ///
288    /// - `ignore_case` - `true` to fold ASCII case when matching.
289    ///
290    /// # Returns
291    ///
292    /// The matcher with the setting applied.
293    #[must_use]
294    pub fn with_ignore_case(mut self, ignore_case: bool) -> Self {
295        self.ignore_case = ignore_case;
296        self.clear_dir_states();
297        self
298    }
299
300    /// Forget per-directory exclude states, which depend on every pattern source and on case
301    /// folding; call after changing either.
302    fn clear_dir_states(&mut self) {
303        self.dir_state_ids.clear();
304        self.dir_states.clear();
305        self.last_dir = None;
306    }
307
308    /// Whether patterns match case-insensitively (see [`Self::with_ignore_case`]).
309    #[must_use]
310    pub fn ignore_case(&self) -> bool {
311        self.ignore_case
312    }
313
314    /// Append patterns from `ls-files --exclude-from` / `-X` files (relative paths resolve from `cwd`).
315    ///
316    /// Matches Git's `EXC_FILE` lists loaded after `core.excludesfile` and `.git/info/exclude`.
317    pub fn add_exclude_from_files(&mut self, paths: &[PathBuf], cwd: &Path) -> Result<()> {
318        for path in paths {
319            let resolved = if path.is_absolute() {
320                path.clone()
321            } else {
322                cwd.join(path)
323            };
324            let display = path.display().to_string();
325            let mut more = load_rules_from_file(
326                &resolved,
327                IgnoreSource::CoreExcludesFile(display),
328                String::new(),
329                false,
330                &mut self.warnings,
331            )?;
332            self.exclude_from_rules.append(&mut more);
333        }
334        self.clear_dir_states();
335        Ok(())
336    }
337
338    /// Override the per-directory exclude filename (`ls-files --exclude-per-directory=<file>`).
339    ///
340    /// When unset, per-directory excludes are read from `.gitignore` (Git's standard name).
341    pub fn set_per_directory_name(&mut self, name: &str) {
342        self.per_directory_name = Some(name.to_owned());
343        self.read_per_directory = true;
344        // The cache is keyed by directory only; clear it so a new filename is honored.
345        self.gitignore_cache.clear();
346        self.rule_sets.clear();
347        self.clear_dir_states();
348    }
349
350    /// Append patterns from `ls-files --exclude` / `-x` (Git command-line exclude list).
351    pub fn add_cli_excludes(&mut self, patterns: &[String]) {
352        for pat in patterns {
353            if let Some(rule) = parse_rule_line(
354                pat,
355                1,
356                IgnoreSource::PerDirectory("--exclude option".into()),
357                "",
358            ) {
359                self.cli_rules.push(rule);
360            }
361        }
362        self.clear_dir_states();
363    }
364
365    /// Take any warnings accumulated while loading ignore files (caller prints to stderr).
366    #[must_use]
367    pub fn take_warnings(&mut self) -> Vec<String> {
368        std::mem::take(&mut self.warnings)
369    }
370
371    /// Whether `repo_rel_path` is ignored, without computing verbose `-v` match metadata.
372    ///
373    /// Prefer this on hot paths (for example untracked directory walks) that only need the
374    /// boolean result.
375    pub fn is_path_ignored(
376        &mut self,
377        repo: &Repository,
378        index: Option<&Index>,
379        repo_rel_path: &str,
380        is_dir: bool,
381    ) -> Result<bool> {
382        if is_tracked(index, repo_rel_path) {
383            return Ok(false);
384        }
385        let state = self.dir_state_id(repo, index, parent_dir(repo_rel_path), false)?;
386        let state = &self.dir_states[state];
387        if state.excluded.is_some() {
388            return Ok(true);
389        }
390        Ok(self
391            .last_matching_rule(repo_rel_path, is_dir, &state.stack)
392            .is_some_and(|rule| !rule.negative))
393    }
394
395    /// Check whether a repository-relative path is ignored.
396    ///
397    /// # Parameters
398    ///
399    /// - `repo` - repository handle.
400    /// - `index` - optional index; when present, tracked entries are not ignored. The lookup is
401    ///   exact-case even under `core.ignorecase`, like `git check-ignore`; work-tree walks that
402    ///   must treat case variants of tracked paths as tracked check that before asking.
403    /// - `repo_rel_path` - normalized repository-relative path with `/` separators.
404    /// - `is_dir` - whether the queried path is a directory.
405    ///
406    /// # Returns
407    ///
408    /// Tuple `(ignored, match_info)` where `match_info` is the last matching
409    /// pattern (including negated matches).
410    ///
411    /// # Errors
412    ///
413    /// Returns [`Error::Io`] when a relevant `.gitignore` cannot be read.
414    pub fn check_path(
415        &mut self,
416        repo: &Repository,
417        index: Option<&Index>,
418        repo_rel_path: &str,
419        is_dir: bool,
420    ) -> Result<(bool, Option<IgnoreMatch>)> {
421        self.check_path_with_ancestors(repo, index, repo_rel_path, is_dir, false)
422    }
423
424    /// [`Self::check_path`], where `ancestors_are_dirs` records that the caller already
425    /// knows no ancestor of `repo_rel_path` is a regular file (because `stat` of the path
426    /// succeeded or failed with "not found"), so new directories need no `stat` of their own.
427    fn check_path_with_ancestors(
428        &mut self,
429        repo: &Repository,
430        index: Option<&Index>,
431        repo_rel_path: &str,
432        is_dir: bool,
433        ancestors_are_dirs: bool,
434    ) -> Result<(bool, Option<IgnoreMatch>)> {
435        if is_tracked(index, repo_rel_path) {
436            return Ok((false, None));
437        }
438        let parent = parent_dir(repo_rel_path);
439        let state = self.dir_state_id(repo, index, parent, ancestors_are_dirs)?;
440        let state = &self.dir_states[state];
441        // A path under an excluded directory stays excluded: a later `!dir/file` cannot
442        // re-include it (gitignore(5)).
443        if let Some(ancestor_match) = &state.excluded {
444            return Ok((true, Some(ancestor_match.clone())));
445        }
446        let Some(rule) = self.last_matching_rule(repo_rel_path, is_dir, &state.stack) else {
447            return Ok((false, None));
448        };
449        if rule.negative {
450            return Ok((false, Some(rule.into())));
451        }
452        let attributed = self
453            .rules_in_priority_order(&state.stack)
454            .filter(|candidate| candidate.directory_only)
455            .filter(|candidate| rule_matches(candidate, repo_rel_path, is_dir, self.ignore_case))
456            .reduce(|best, candidate| {
457                let key = |r: &IgnoreRule| (base_dir_depth(&r.base_dir), r.line_number);
458                if key(candidate) < key(best) {
459                    candidate
460                } else {
461                    best
462                }
463            })
464            .unwrap_or(rule);
465        Ok((true, Some(attributed.into())))
466    }
467
468    /// The rule deciding `path`, given the per-directory exclude `stack` (root first) of its
469    /// parent directory.
470    ///
471    /// Pattern groups are consulted in Git's priority order: command-line excludes, then the
472    /// per-directory files from the deepest directory up, then `--exclude-from`,
473    /// `core.excludesfile` and `info/exclude` as one group. The first group with a match
474    /// decides, and within a group the last matching line wins.
475    fn last_matching_rule(&self, path: &str, is_dir: bool, stack: &[usize]) -> Option<&IgnoreRule> {
476        let fold = self.ignore_case;
477        last_match_in("", &self.cli_rules, path, is_dir, fold)
478            .or_else(|| {
479                stack.iter().rev().find_map(|&id| {
480                    let set = self.rule_sets.get(id)?;
481                    last_match_in(&set.base_dir, &set.rules, path, is_dir, fold)
482                })
483            })
484            .or_else(|| last_match_in("", &self.info_rules, path, is_dir, fold))
485            .or_else(|| last_match_in("", &self.global_rules, path, is_dir, fold))
486            .or_else(|| last_match_in("", &self.exclude_from_rules, path, is_dir, fold))
487    }
488
489    /// Every rule that can apply under `stack`, in the order Git's `check-ignore -v` scans
490    /// them: command-line excludes, per-directory files deepest first, then the file group.
491    fn rules_in_priority_order<'a>(
492        &'a self,
493        stack: &'a [usize],
494    ) -> impl Iterator<Item = &'a IgnoreRule> + 'a {
495        let per_dir = stack
496            .iter()
497            .rev()
498            .filter_map(|&id| self.rule_sets.get(id))
499            .flat_map(|set| set.rules.iter());
500        self.cli_rules
501            .iter()
502            .chain(per_dir)
503            .chain(self.exclude_from_rules.iter())
504            .chain(self.global_rules.iter())
505            .chain(self.info_rules.iter())
506    }
507
508    /// Load per-directory exclude files for `dir_rel` when depth-first walking the work tree.
509    ///
510    /// Idempotent: repeated calls for the same directory reuse the in-memory cache.
511    pub fn note_directory_entered(
512        &mut self,
513        repo: &Repository,
514        index: Option<&Index>,
515        dir_rel: &str,
516    ) -> Result<()> {
517        if !self.read_per_directory {
518            return Ok(());
519        }
520        if self.gitignore_cache.contains_key(dir_rel) {
521            return Ok(());
522        }
523        self.known_worktree_directories.insert(dir_rel.to_owned());
524        self.rule_set_id(repo, index, dir_rel)?;
525        Ok(())
526    }
527
528    /// Record that the walk entered work-tree directory `dir_rel` without reading its
529    /// per-directory exclude file yet.
530    ///
531    /// The file is read by the first query for a path inside the directory, so a directory
532    /// whose entries are all tracked never opens it (as in Git, where exclude rules are only
533    /// consulted for untracked paths).
534    pub(crate) fn note_directory_known(&mut self, dir_rel: &str) {
535        if self.read_per_directory
536            && !self.gitignore_cache.contains_key(dir_rel)
537            && !self.known_worktree_directories.contains(dir_rel)
538        {
539            self.known_worktree_directories.insert(dir_rel.to_owned());
540        }
541    }
542
543    /// Id of the parsed per-directory exclude file of `dir`, reading it on first use.
544    fn rule_set_id(
545        &mut self,
546        repo: &Repository,
547        index: Option<&Index>,
548        dir: &str,
549    ) -> Result<usize> {
550        if let Some(&id) = self.gitignore_cache.get(dir) {
551            return Ok(id);
552        }
553        let per_dir_name = self.per_directory_name.as_deref();
554        let rules = load_gitignore_for_dir(repo, index, dir, per_dir_name, &mut self.warnings)?;
555        let id = self.rule_sets.len();
556        self.rule_sets.push(DirRuleSet {
557            base_dir: dir.to_owned(),
558            rules,
559        });
560        self.gitignore_cache.insert(dir.to_owned(), id);
561        Ok(id)
562    }
563
564    /// Id of the exclude state of directory `dir` (`""` for the root), building it on first use.
565    ///
566    /// Sibling paths share their parent's state, so a batch of queries reads each exclude
567    /// file, stats each directory and probes each directory against the patterns once.
568    /// `ancestors_are_dirs` skips that `stat` when no ancestor can be a regular file.
569    fn dir_state_id(
570        &mut self,
571        repo: &Repository,
572        index: Option<&Index>,
573        dir: &str,
574        ancestors_are_dirs: bool,
575    ) -> Result<usize> {
576        if let Some((last, id)) = &self.last_dir {
577            if last == dir {
578                return Ok(*id);
579            }
580        }
581        let id = match self.dir_state_ids.get(dir) {
582            Some(&id) => id,
583            None => self.build_dir_state(repo, index, dir, ancestors_are_dirs)?,
584        };
585        match &mut self.last_dir {
586            Some((last, last_id)) => {
587                last.clear();
588                last.push_str(dir);
589                *last_id = id;
590            }
591            None => self.last_dir = Some((dir.to_owned(), id)),
592        }
593        Ok(id)
594    }
595
596    /// Build the state of `dir` from its parent's, as Git's `prep_exclude` does when it
597    /// descends one level: probe `dir` itself against the patterns loaded so far, then push
598    /// `dir`'s own exclude file.
599    fn build_dir_state(
600        &mut self,
601        repo: &Repository,
602        index: Option<&Index>,
603        dir: &str,
604        ancestors_are_dirs: bool,
605    ) -> Result<usize> {
606        let state = if dir.is_empty() {
607            let stack = if self.read_per_directory {
608                vec![self.rule_set_id(repo, index, "")?]
609            } else {
610                Vec::new()
611            };
612            DirState {
613                stack,
614                extendable: self.read_per_directory,
615                excluded: None,
616            }
617        } else {
618            let parent = parent_dir(dir);
619            let parent_id = match self.dir_state_ids.get(parent) {
620                Some(&id) => id,
621                None => self.build_dir_state(repo, index, parent, ancestors_are_dirs)?,
622            };
623            let parent_state = &self.dir_states[parent_id];
624            let excluded = parent_state.excluded.clone().or_else(|| {
625                self.last_matching_rule(dir, true, &parent_state.stack)
626                    .filter(|rule| !rule.negative)
627                    .map(IgnoreMatch::from)
628            });
629            let mut stack = parent_state.stack.clone();
630            let mut extendable = parent_state.extendable;
631            // Git does not treat a file as a directory when loading per-directory excludes
632            // (`tracked.txt/.gitignore` must not be probed), nor anything below it.
633            if extendable
634                && (self.known_worktree_directories.contains(dir)
635                    || ancestors_are_dirs
636                    || !worktree_path_is_existing_file(repo, dir))
637            {
638                stack.push(self.rule_set_id(repo, index, dir)?);
639            } else {
640                extendable = false;
641            }
642            DirState {
643                stack,
644                extendable,
645                excluded,
646            }
647        };
648        let id = self.dir_states.len();
649        self.dir_states.push(state);
650        self.dir_state_ids.insert(dir.to_owned(), id);
651        Ok(id)
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(line: &str) -> &str {
809    let bytes = line.as_bytes();
810    let mut last_space_start: Option<usize> = None;
811    let mut i = 0usize;
812    while let Some(&byte) = bytes.get(i) {
813        match byte {
814            b' ' => {
815                last_space_start.get_or_insert(i);
816                i += 1;
817            }
818            b'\\' => {
819                last_space_start = None;
820                i += 2;
821            }
822            _ => {
823                last_space_start = None;
824                i += 1;
825            }
826        }
827    }
828    // Spaces are ASCII, so `start` is a char boundary.
829    last_space_start.map_or(line, |start| &line[..start])
830}
831
832fn parse_rule_line(
833    line: &str,
834    line_number: usize,
835    source: IgnoreSource,
836    base_dir: &str,
837) -> Option<IgnoreRule> {
838    let pattern_text = trim_trailing_spaces_git(line.strip_suffix('\r').unwrap_or(line));
839    if pattern_text.is_empty() || pattern_text.starts_with('#') {
840        return None;
841    }
842    let (negative, raw) = match pattern_text.strip_prefix('!') {
843        Some(rest) => (true, rest),
844        None => (false, pattern_text),
845    };
846    let (anchored, raw) = match raw.strip_prefix('/') {
847        Some(rest) => (true, rest),
848        None => (false, raw),
849    };
850    let (directory_only, body) = match raw.strip_suffix('/') {
851        Some(rest) => (true, rest),
852        None => (false, raw),
853    };
854    if raw.is_empty() || body.is_empty() {
855        return None;
856    }
857    Some(IgnoreRule {
858        source,
859        line_number,
860        pattern_text: pattern_text.to_owned(),
861        negative,
862        directory_only,
863        anchored,
864        has_slash: body.contains('/'),
865        body: body.to_owned(),
866        base_dir: base_dir.to_owned(),
867        match_kind: classify_ignore_pattern(body),
868    })
869}
870
871fn classify_ignore_pattern(body: &str) -> IgnorePatternKind {
872    // Backslashes are wildmatch escapes (`foo\ bar` matches a literal space); never treat as
873    // a plain literal string.
874    if body.contains('\\') || body.contains(['?', '[']) {
875        return IgnorePatternKind::Wild;
876    }
877    if !body.contains('*') {
878        return IgnorePatternKind::Literal(body.to_owned());
879    }
880    if let Some(rest) = body.strip_prefix('*') {
881        if !rest.contains('*') && !rest.contains('/') {
882            return IgnorePatternKind::Suffix(rest.to_owned());
883        }
884    }
885    if let Some(rest) = body.strip_suffix('*') {
886        if !rest.contains('*') && !rest.contains('/') {
887            return IgnorePatternKind::Prefix(rest.to_owned());
888        }
889    }
890    IgnorePatternKind::Wild
891}
892
893fn base_dir_depth(base: &str) -> usize {
894    if base.is_empty() {
895        return 0;
896    }
897    base.split('/').count()
898}
899
900fn rule_matches(rule: &IgnoreRule, repo_rel_path: &str, is_dir: bool, ignore_case: bool) -> bool {
901    let Some(target) = MatchTarget::new(&rule.base_dir, repo_rel_path, ignore_case) else {
902        return false;
903    };
904    if ignore_case {
905        target.matches::<true>(rule, is_dir)
906    } else {
907        target.matches::<false>(rule, is_dir)
908    }
909}
910
911/// The last rule in `rules` (all relative to `base_dir`) that matches `path`.
912///
913/// Strips the base directory and finds the basename once for the whole list, and scans
914/// from the last line so the first hit is the winner.
915fn last_match_in<'a>(
916    base_dir: &str,
917    rules: &'a [IgnoreRule],
918    path: &str,
919    is_dir: bool,
920    ignore_case: bool,
921) -> Option<&'a IgnoreRule> {
922    if rules.is_empty() {
923        return None;
924    }
925    let target = MatchTarget::new(base_dir, path, ignore_case)?;
926    if ignore_case {
927        rules
928            .iter()
929            .rev()
930            .find(|rule| target.matches::<true>(rule, is_dir))
931    } else {
932        rules
933            .iter()
934            .rev()
935            .find(|rule| target.matches::<false>(rule, is_dir))
936    }
937}
938
939/// A queried path as seen from one exclude file's directory, borrowed from the query.
940struct MatchTarget<'p> {
941    /// The path relative to the exclude file's directory (never empty).
942    rel: &'p str,
943    /// The last component of `rel`.
944    basename: &'p str,
945}
946
947impl<'p> MatchTarget<'p> {
948    /// `None` when `path` is not strictly inside `base_dir`, so no rule there can match.
949    fn new(base_dir: &str, path: &'p str, ignore_case: bool) -> Option<Self> {
950        let rel = strip_base(base_dir, path, ignore_case)?;
951        if rel.is_empty() {
952            return None;
953        }
954        let basename = rel.rsplit_once('/').map_or(rel, |(_, name)| name);
955        Some(Self { rel, basename })
956    }
957
958    /// Whether `rule` matches, specialized on case folding so the default case-sensitive
959    /// path carries no per-comparison `core.ignorecase` checks.
960    ///
961    /// Only the queried path itself is tested: slash-free patterns match its basename,
962    /// patterns with a slash match the whole path relative to the rule's directory. A path
963    /// under an excluded directory inherits that exclusion from its parent directory's
964    /// state, which probes each ancestor with the same test, so `tags` followed by `!tags/`
965    /// leaves `tags/a.txt` unignored while `tags` alone ignores it through its parent.
966    fn matches<const FOLD: bool>(&self, rule: &IgnoreRule, is_dir: bool) -> bool {
967        // Directory-only patterns (`foo/`) never match files, so `!pkg/` cannot re-include
968        // files under `pkg/` that `*` still ignores (issue #1650).
969        if rule.directory_only && !is_dir {
970            return false;
971        }
972        if rule.has_slash || rule.anchored {
973            return rule_body_matches(rule, self.rel, true, FOLD);
974        }
975        rule_body_matches(rule, self.basename, false, FOLD)
976    }
977}
978
979fn is_tracked(index: Option<&Index>, repo_rel_path: &str) -> bool {
980    let Some(index) = index else {
981        return false;
982    };
983    // Trailing `/` is a directory query; Git does not apply the index tracked exemption
984    // (`tracked.txt/` can match exclude rules even when `tracked.txt` is staged).
985    if repo_rel_path.ends_with('/') {
986        return false;
987    }
988    index.get(repo_rel_path.as_bytes(), 0).is_some()
989}
990
991/// True when `repo_rel_path` exists in the work tree as a regular file (not a directory).
992fn worktree_path_is_existing_file(repo: &Repository, repo_rel_path: &str) -> bool {
993    let Some(work_tree) = repo.work_tree() else {
994        return false;
995    };
996    let path = work_tree.join(repo_rel_path);
997    path.metadata().map(|m| m.is_file()).unwrap_or(false)
998}
999
1000/// Whether a queried path names a directory: it ends in `/` or is one in the work tree.
1001///
1002/// `scratch` is reused across a batch so each query costs one `stat` and no allocation.
1003///
1004/// Returns `(is_dir, ancestors_are_dirs)`. The second is `true` when the `stat` shows that no
1005/// ancestor of the path is a regular file: it succeeded, or failed because a component is
1006/// missing (a file ancestor fails with "not a directory" instead).
1007fn probe_query_path(
1008    work_tree: Option<&Path>,
1009    repo_rel_path: &str,
1010    scratch: &mut PathBuf,
1011) -> (bool, bool) {
1012    if repo_rel_path.ends_with('/') {
1013        return (true, false);
1014    }
1015    let Some(work_tree) = work_tree else {
1016        return (false, false);
1017    };
1018    scratch.as_mut_os_string().clear();
1019    scratch.push(work_tree);
1020    scratch.push(repo_rel_path);
1021    match scratch.metadata() {
1022        Ok(meta) => (meta.is_dir(), true),
1023        // Windows reports a file ancestor as "path not found", so only Unix can tell.
1024        Err(err) => (
1025            false,
1026            cfg!(unix) && err.kind() == std::io::ErrorKind::NotFound,
1027        ),
1028    }
1029}
1030
1031impl Repository {
1032    /// Check whether paths are ignored, reusing one [`IgnoreMatcher`] for the whole batch.
1033    ///
1034    /// Tracked index entries are never reported as ignored. Each result includes verbose-style
1035    /// match metadata compatible with `git check-ignore -v`.
1036    ///
1037    /// # Parameters
1038    ///
1039    /// - `paths` - repository-relative paths with `/` separators (as returned by
1040    ///   [`normalize_repo_relative`]).
1041    /// - `index` - current index; tracked paths are treated as not ignored.
1042    ///
1043    /// # Errors
1044    ///
1045    /// Returns [`Error::Io`] when loading exclude files fails.
1046    pub fn ignore_check(&self, paths: &[String], index: &Index) -> Result<Vec<IgnoreCheck>> {
1047        let mut matcher = IgnoreMatcher::from_repository(self)?;
1048        let mut out = Vec::with_capacity(paths.len());
1049        let mut scratch = PathBuf::new();
1050        for path in paths {
1051            let (is_dir, ancestors_are_dirs) =
1052                probe_query_path(self.work_tree(), path, &mut scratch);
1053            let (ignored, matched) = matcher.check_path_with_ancestors(
1054                self,
1055                Some(index),
1056                path,
1057                is_dir,
1058                ancestors_are_dirs,
1059            )?;
1060            out.push(IgnoreCheck {
1061                path: path.clone(),
1062                ignored,
1063                matched,
1064            });
1065        }
1066        Ok(out)
1067    }
1068}
1069
1070/// Outcome of reading a per-directory pattern file that Git refuses to follow as a symlink.
1071enum InTreePatternFile {
1072    Missing,
1073    Symlink,
1074    Content(String),
1075}
1076
1077/// Read `path` without following a final symlink, so a link (dangling, or to a FIFO or
1078/// device) is reported instead of opened. A missing file costs a single failed open on Unix.
1079fn read_in_tree_pattern_file(path: &Path) -> Result<InTreePatternFile> {
1080    #[cfg(unix)]
1081    let opened = {
1082        use std::os::unix::fs::OpenOptionsExt;
1083        fs::OpenOptions::new()
1084            .read(true)
1085            .custom_flags(libc::O_NOFOLLOW)
1086            .open(path)
1087    };
1088    #[cfg(not(unix))]
1089    let opened = match fs::symlink_metadata(path) {
1090        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1091            return Ok(InTreePatternFile::Missing);
1092        }
1093        Err(err) => return Err(Error::Io(err)),
1094        Ok(meta) if meta.file_type().is_symlink() => return Ok(InTreePatternFile::Symlink),
1095        Ok(_) => fs::File::open(path),
1096    };
1097    let mut file = match opened {
1098        Ok(file) => file,
1099        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1100            return Ok(InTreePatternFile::Missing);
1101        }
1102        Err(_) if fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) => {
1103            return Ok(InTreePatternFile::Symlink);
1104        }
1105        Err(err) => return Err(Error::Io(err)),
1106    };
1107    let mut content = String::new();
1108    std::io::Read::read_to_string(&mut file, &mut content).map_err(Error::Io)?;
1109    Ok(InTreePatternFile::Content(content))
1110}
1111
1112fn read_optional_text(path: &Path) -> Result<Option<String>> {
1113    match fs::read_to_string(path) {
1114        Ok(content) => Ok(Some(content)),
1115        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1116        Err(err) => Err(Error::Io(err)),
1117    }
1118}
1119
1120/// Path of `path` relative to the `.gitignore` directory `base`, or `None` when `path` is not
1121/// inside it. With `ignore_case`, the base compares without regard to ASCII case.
1122fn strip_base<'a>(base: &str, path: &'a str, ignore_case: bool) -> Option<&'a str> {
1123    if base.is_empty() {
1124        return Some(path);
1125    }
1126    if !starts_with_fold(path, base, ignore_case) {
1127        return None;
1128    }
1129    match path.as_bytes().get(base.len()) {
1130        None => Some(""),
1131        // `base.len()` is a char boundary: the byte there is ASCII `/`.
1132        Some(b'/') => path.get(base.len() + 1..),
1133        Some(_) => None,
1134    }
1135}
1136
1137fn eq_fold(text: &str, pattern: &str, ignore_case: bool) -> bool {
1138    if ignore_case {
1139        text.eq_ignore_ascii_case(pattern)
1140    } else {
1141        text == pattern
1142    }
1143}
1144
1145fn starts_with_fold(text: &str, prefix: &str, ignore_case: bool) -> bool {
1146    if !ignore_case {
1147        return text.starts_with(prefix);
1148    }
1149    text.as_bytes()
1150        .get(..prefix.len())
1151        .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_bytes()))
1152}
1153
1154fn ends_with_fold(text: &str, suffix: &str, ignore_case: bool) -> bool {
1155    if !ignore_case {
1156        return text.ends_with(suffix);
1157    }
1158    text.len()
1159        .checked_sub(suffix.len())
1160        .and_then(|start| text.as_bytes().get(start..))
1161        .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix.as_bytes()))
1162}
1163
1164fn parent_dir(path: &str) -> &str {
1165    match path.rsplit_once('/') {
1166        Some((parent, _)) => parent,
1167        None => "",
1168    }
1169}
1170
1171fn pattern_matches_kind(
1172    kind: &IgnorePatternKind,
1173    pattern: &str,
1174    text: &str,
1175    pathname: bool,
1176    ignore_case: bool,
1177) -> bool {
1178    match kind {
1179        IgnorePatternKind::Literal(lit) => eq_fold(text, lit, ignore_case),
1180        IgnorePatternKind::Prefix(prefix) => starts_with_fold(text, prefix, ignore_case),
1181        IgnorePatternKind::Suffix(suffix) => ends_with_fold(text, suffix, ignore_case),
1182        IgnorePatternKind::Wild => {
1183            let mut flags = if pathname { WM_PATHNAME } else { 0 };
1184            if ignore_case {
1185                flags |= WM_CASEFOLD;
1186            }
1187            wildmatch(pattern.as_bytes(), text.as_bytes(), flags)
1188        }
1189    }
1190}
1191
1192fn rule_body_matches(rule: &IgnoreRule, text: &str, pathname: bool, ignore_case: bool) -> bool {
1193    pattern_matches_kind(&rule.match_kind, &rule.body, text, pathname, ignore_case)
1194}
1195
1196/// One line from a sparse-checkout specification blob (`--filter=sparse:oid=…`).
1197#[derive(Debug, Clone)]
1198struct SparsePattern {
1199    negative: bool,
1200    directory_only: bool,
1201    anchored: bool,
1202    has_slash: bool,
1203    body: String,
1204}
1205
1206impl SparsePattern {
1207    fn from_line(line: &str) -> Option<Self> {
1208        let raw_line = trim_trailing_spaces_git(line.strip_suffix('\r').unwrap_or(line));
1209        if raw_line.is_empty() || raw_line.starts_with('#') {
1210            return None;
1211        }
1212
1213        let mut raw = raw_line.to_owned();
1214
1215        let mut negative = false;
1216        if let Some(rest) = raw.strip_prefix('!') {
1217            negative = true;
1218            raw = rest.to_owned();
1219        }
1220        if raw.is_empty() {
1221            return None;
1222        }
1223
1224        // Git `parse_path_pattern`: trailing `/` sets `PATTERN_FLAG_MUSTBEDIR` and shortens the
1225        // active pattern length without turning `/*` into an empty body (that would drop the rule).
1226        let mut directory_only = false;
1227        if raw.len() > 1 && raw.ends_with('/') {
1228            directory_only = true;
1229            raw.pop();
1230        }
1231        if raw.is_empty() {
1232            return None;
1233        }
1234
1235        let mut anchored = false;
1236        if let Some(rest) = raw.strip_prefix('/') {
1237            anchored = true;
1238            raw = rest.to_owned();
1239        }
1240        // After `/*` → pop `/` we get `"/"` then strip leading `/` → empty. Git keeps this as the
1241        // root glob `*` (include all top-level names in non-cone sparse files).
1242        if raw.is_empty() && anchored && directory_only {
1243            raw = "*".to_owned();
1244            directory_only = false;
1245        } else if raw.is_empty() {
1246            return None;
1247        }
1248
1249        let has_slash = raw.contains('/');
1250        Some(Self {
1251            negative,
1252            directory_only,
1253            anchored,
1254            has_slash,
1255            body: raw,
1256        })
1257    }
1258}
1259
1260/// Whether `p` matches `pathname` for sparse-checkout evaluation.
1261///
1262/// `as_directory` mirrors Git's `dtype == DT_DIR` pass when walking parent paths:
1263/// patterns with a trailing `/` in the sparse file (`PATTERN_FLAG_MUSTBEDIR`) only
1264/// participate in those iterations.
1265fn sparse_pattern_matches(p: &SparsePattern, pathname: &str, as_directory: bool) -> bool {
1266    // Git never gates basename (NODIR) or anchored `/*` patterns on dtype: `match_basename` /
1267    // `match_pathname` run regardless of `DT_DIR` (dir.c). Earlier code rejected `nodir`
1268    // patterns on the parent-directory pass, which wrongly excluded `subsub/added` under
1269    // `/*` + `!sub` + `sub/added` (t1011 subtest 9). The `!/*/` directory-only rule still
1270    // shadows `/*` for nested dirs via reverse-order first-match (t7817/t1091 preserved).
1271    if p.directory_only && !as_directory {
1272        return false;
1273    }
1274    // On-disk `!/*/` parses as directory-only anchored `*`. For regular files we exclude nested
1275    // paths only (`dir/c`, not `a`). When walking parents (`as_directory`), Git still matches this
1276    // pattern against directory paths like `dir` so `dir/c` can be excluded (t7817).
1277    if p.directory_only && p.anchored && !p.has_slash && p.body == "*" {
1278        // Git `match_pathname` for MUSTBEDIR `/*` / `!/*/`: only top-level directory names
1279        // (no `/` in the pathname), including on the parent-directory pass — not every
1280        // `as_directory` path (dir.c / filter_sparse).
1281        let trimmed = pathname.trim_end_matches('/');
1282        return !trimmed.contains('/');
1283    }
1284    if !p.has_slash && !p.anchored {
1285        sparse_unanchored_basename_matches(&p.body, pathname)
1286    } else if p.anchored && !p.has_slash && p.body == "*" && !p.directory_only {
1287        // Sparse line `/*`: include only top-level paths (one segment). `WM_PATHNAME` keeps `*`
1288        // from matching `/`, but we match against the bare pathname; require no `/` so `dir/c` is
1289        // not included by `/*` alone (t7817 — excluded via `!/*/` on the parent directory pass).
1290        !pathname.contains('/')
1291    } else {
1292        wildmatch(p.body.as_bytes(), pathname.as_bytes(), WM_PATHNAME)
1293    }
1294}
1295
1296fn sparse_pattern_matches_path(p: &SparsePattern, pathname: &str) -> bool {
1297    sparse_pattern_matches(p, pathname, false)
1298}
1299
1300/// Parse sparse-checkout lines from a blob (same syntax as `info/sparse-checkout`).
1301#[must_use]
1302pub fn parse_sparse_patterns_from_blob(content: &str) -> Vec<String> {
1303    let mut patterns = Vec::new();
1304    for line in content.lines() {
1305        let t = line.trim_end_matches('\r');
1306        if t.is_empty() || t.starts_with('#') {
1307            continue;
1308        }
1309        patterns.push(t.to_owned());
1310    }
1311    patterns
1312}
1313
1314fn sparse_list_last_match(
1315    pathname: &str,
1316    as_directory: bool,
1317    parsed: &[SparsePattern],
1318) -> Option<bool> {
1319    for p in parsed.iter().rev() {
1320        if sparse_pattern_matches(p, pathname, as_directory) {
1321            return Some(!p.negative);
1322        }
1323    }
1324    None
1325}
1326
1327/// Non-cone sparse-checkout inclusion, matching Git's `path_in_sparse_checkout`.
1328///
1329/// Walks from the full path toward parents (as in `dir.c:path_in_sparse_checkout_1`):
1330/// each step uses last-match-wins over patterns; `UNDECIDED` falls back to the parent
1331/// directory until the decision is made or the path is rejected at the top level.
1332#[must_use]
1333pub fn path_in_sparse_checkout(path: &str, lines: &[String], work_tree: Option<&Path>) -> bool {
1334    ParsedSparsePatterns::parse(lines).includes(path, work_tree)
1335}
1336
1337/// Non-cone sparse-checkout lines parsed once, for repeated [`path_in_sparse_checkout`] checks.
1338#[derive(Debug, Clone)]
1339pub(crate) struct ParsedSparsePatterns(Vec<SparsePattern>);
1340
1341impl ParsedSparsePatterns {
1342    /// Parse sparse-checkout lines; blank lines and comments are dropped.
1343    pub(crate) fn parse(lines: &[String]) -> Self {
1344        Self(
1345            lines
1346                .iter()
1347                .filter_map(|l| SparsePattern::from_line(l))
1348                .collect(),
1349        )
1350    }
1351
1352    /// Whether `path` is included; `work_tree` lets on-disk directories match directory-only rules.
1353    pub(crate) fn includes(&self, path: &str, work_tree: Option<&Path>) -> bool {
1354        if path.is_empty() || self.0.is_empty() {
1355            return true;
1356        }
1357
1358        let mut end = path.len();
1359        let mut as_directory = false;
1360
1361        loop {
1362            let pathname = &path[..end];
1363            let dtype_is_dir =
1364                work_tree.is_some_and(|wt| wt.join(pathname).is_dir()) || as_directory;
1365
1366            match sparse_list_last_match(pathname, dtype_is_dir, &self.0) {
1367                Some(true) => return true,
1368                Some(false) => return false,
1369                None => {
1370                    let Some(slash) = path[..end].rfind('/') else {
1371                        // Top-level path with no matching rule: Git stops here (UNDECIDED → excluded),
1372                        // not at an empty pathname.
1373                        return false;
1374                    };
1375                    end = slash;
1376                    as_directory = true;
1377                }
1378            }
1379        }
1380    }
1381}
1382
1383/// Last-match-wins sparse semantics for `rev-list --filter=sparse:oid=…` (non-cone).
1384///
1385/// Unanchored patterns without `/` use Git-style basename rules: `pat` matches `pat`,
1386/// `pat.ext`, and `pat/…` paths. Anchored patterns and patterns containing `/` use
1387/// pathname wildmatch with `WM_PATHNAME`.
1388///
1389/// Returns `None` if no pattern matched (`UNDECIDED`).
1390#[must_use]
1391pub fn path_matches_sparse_pattern_list(pathname: &str, lines: &[String]) -> Option<bool> {
1392    let parsed: Vec<SparsePattern> = lines
1393        .iter()
1394        .filter_map(|l| SparsePattern::from_line(l))
1395        .collect();
1396    if parsed.is_empty() {
1397        return None;
1398    }
1399    for p in parsed.iter().rev() {
1400        if sparse_pattern_matches_path(p, pathname) {
1401            return Some(!p.negative);
1402        }
1403    }
1404    None
1405}
1406
1407fn sparse_unanchored_basename_matches(pat: &str, path: &str) -> bool {
1408    let basename = path.rsplit('/').next().unwrap_or(path);
1409    wildmatch(pat.as_bytes(), basename.as_bytes(), 0)
1410}
1411
1412/// Returns the submodule path if `repo_rel_path` names something inside a gitlink entry.
1413#[must_use]
1414pub fn submodule_containing_path(repo_rel_path: &str, index: &Index) -> Option<String> {
1415    let bytes = repo_rel_path.as_bytes();
1416    if bytes.is_empty() {
1417        return None;
1418    }
1419    let mut best: Option<String> = None;
1420    let mut end = 0usize;
1421    while end < bytes.len() {
1422        let Some(slash) = bytes[end..].iter().position(|&b| b == b'/') else {
1423            break;
1424        };
1425        end += slash;
1426        if let Some(entry) = index.get(&bytes[..end], 0) {
1427            if entry.mode == MODE_GITLINK {
1428                best = Some(String::from_utf8_lossy(&bytes[..end]).into_owned());
1429            }
1430        }
1431        end += 1;
1432    }
1433    best
1434}
1435
1436/// Convert a user-supplied path into a normalized repository-relative path.
1437///
1438/// For many paths from the same directory, build one [`RepoPathNormalizer`] instead.
1439///
1440/// # Parameters
1441///
1442/// - `repo` - repository handle.
1443/// - `cwd` - current working directory.
1444/// - `path` - user input path string.
1445///
1446/// # Errors
1447///
1448/// Returns [`Error::PathError`] if the repository has no work tree or the path resolves
1449/// outside it.
1450pub fn normalize_repo_relative(repo: &Repository, cwd: &Path, path: &str) -> Result<String> {
1451    RepoPathNormalizer::new(repo, cwd)?.normalize(path)
1452}
1453
1454/// Converts user-supplied paths, relative to one working directory, into normalized
1455/// repository-relative paths (as [`normalize_repo_relative`] does for a single path).
1456///
1457/// The working directory's position in the work tree is resolved once, so each relative path
1458/// is normalized lexically on its `/`-separated components with a single allocation for the
1459/// result. Absolute paths and paths that climb above the work tree root take the general
1460/// route, which also recognizes alternate spellings of the work tree (such as macOS
1461/// `/private/tmp` for `/tmp`).
1462///
1463/// # Examples
1464///
1465/// ```no_run
1466/// use std::path::Path;
1467/// use grit_lib::config::environment::RepositoryOptions;
1468/// use grit_lib::repo::Repository;
1469/// use grit_lib::worktree::ignore::RepoPathNormalizer;
1470///
1471/// # fn main() -> grit_lib::error::Result<()> {
1472/// let repo = Repository::discover(None, &RepositoryOptions::empty())?;
1473/// let cwd = repo.work_tree().unwrap_or(Path::new(".")).join("src");
1474/// let normalizer = RepoPathNormalizer::new(&repo, &cwd)?;
1475/// assert_eq!(normalizer.normalize("./lib/../main.rs")?, "src/main.rs");
1476/// assert_eq!(normalizer.normalize("../docs/")?, "docs/");
1477/// # Ok(())
1478/// # }
1479/// ```
1480#[derive(Debug)]
1481pub struct RepoPathNormalizer<'a> {
1482    work_tree: &'a Path,
1483    cwd: &'a Path,
1484    /// `cwd` relative to the work tree root with `/` separators, when it lies inside it.
1485    cwd_rel: Option<String>,
1486}
1487
1488impl<'a> RepoPathNormalizer<'a> {
1489    /// Prepare to normalize paths given relative to `cwd`.
1490    ///
1491    /// # Parameters
1492    ///
1493    /// - `repo` - repository whose work tree paths are made relative to.
1494    /// - `cwd` - directory relative input paths start from (usually the process's current
1495    ///   directory); it may lie outside the work tree.
1496    ///
1497    /// # Errors
1498    ///
1499    /// Returns [`Error::PathError`] when `repo` has no work tree.
1500    pub fn new(repo: &'a Repository, cwd: &'a Path) -> Result<Self> {
1501        let Some(work_tree) = repo.work_tree() else {
1502            return Err(Error::PathError(
1503                "this operation must be run in a work tree".to_owned(),
1504            ));
1505        };
1506        let cwd_rel = normalize_path(cwd)
1507            .strip_prefix(work_tree)
1508            .ok()
1509            .map(crate::git_path::path_to_git_slash);
1510        Ok(Self {
1511            work_tree,
1512            cwd,
1513            cwd_rel,
1514        })
1515    }
1516
1517    /// Normalize one user-supplied path.
1518    ///
1519    /// `.` and `..` components are resolved lexically, a trailing `/` (a directory query) is
1520    /// kept, the work tree root itself becomes `""`, and paths starting with `:` are returned
1521    /// unchanged.
1522    ///
1523    /// # Parameters
1524    ///
1525    /// - `path` - path as the user typed it, relative to the normalizer's directory or
1526    ///   absolute.
1527    ///
1528    /// # Returns
1529    ///
1530    /// The repository-relative path with `/` separators.
1531    ///
1532    /// # Errors
1533    ///
1534    /// Returns [`Error::PathError`] if the path resolves outside the work tree.
1535    pub fn normalize(&self, path: &str) -> Result<String> {
1536        if path.starts_with(':') {
1537            return Ok(path.to_owned());
1538        }
1539        match self.normalize_lexically(path) {
1540            Some(rel) => Ok(rel),
1541            None => self.normalize_via_filesystem_paths(path),
1542        }
1543    }
1544
1545    /// The fast route: a relative path that stays inside the work tree, resolved from the
1546    /// precomputed working directory prefix. `None` sends the path down the general route.
1547    fn normalize_lexically(&self, path: &str) -> Option<String> {
1548        let cwd_rel = self.cwd_rel.as_deref()?;
1549        if cfg!(windows) || Path::new(path).is_absolute() {
1550            return None;
1551        }
1552        let mut rel = String::with_capacity(cwd_rel.len() + path.len() + 1);
1553        rel.push_str(cwd_rel);
1554        for component in path.split('/') {
1555            match component {
1556                "" | "." => {}
1557                ".." => {
1558                    if rel.is_empty() {
1559                        return None;
1560                    }
1561                    rel.truncate(rel.rfind('/').unwrap_or(0));
1562                }
1563                name => {
1564                    if !rel.is_empty() {
1565                        rel.push('/');
1566                    }
1567                    rel.push_str(name);
1568                }
1569            }
1570        }
1571        if path.ends_with('/') && !rel.is_empty() {
1572            rel.push('/');
1573        }
1574        Some(rel)
1575    }
1576
1577    fn normalize_via_filesystem_paths(&self, path: &str) -> Result<String> {
1578        let work_tree = self.work_tree;
1579        let query_as_dir = path.ends_with('/');
1580        let input = Path::new(path);
1581        let combined = if input.is_absolute() {
1582            input.to_path_buf()
1583        } else {
1584            self.cwd.join(input)
1585        };
1586        let normalized = normalize_path(&combined);
1587        // Repository opening already resolves the worktree root. Most queries share that
1588        // spelling, so avoid canonicalizing the same root again for every path in a batch.
1589        // Keep the filesystem-alias fallback for spellings such as macOS /tmp and /private/tmp.
1590        let mut rel = match normalized.strip_prefix(work_tree) {
1591            Ok(relative) => crate::git_path::path_to_git_slash(relative),
1592            Err(_) => {
1593                crate::git_path::strip_worktree_prefix(&normalized, work_tree).ok_or_else(|| {
1594                    Error::PathError(format!("path '{path}' is outside repository work tree"))
1595                })?
1596            }
1597        };
1598        if query_as_dir && !rel.is_empty() && !rel.ends_with('/') {
1599            rel.push('/');
1600        }
1601        Ok(rel)
1602    }
1603}
1604
1605fn normalize_path(path: &Path) -> PathBuf {
1606    let mut out = PathBuf::new();
1607    for component in path.components() {
1608        match component {
1609            Component::CurDir => {}
1610            Component::ParentDir => {
1611                out.pop();
1612            }
1613            other => out.push(other.as_os_str()),
1614        }
1615    }
1616    out
1617}
1618
1619#[cfg(test)]
1620mod normalization_tests {
1621    use super::*;
1622
1623    #[test]
1624    fn bare_repository_queries_return_a_path_error() {
1625        let dir = tempfile::tempdir().unwrap();
1626        let repo = crate::repo::init_repository(
1627            dir.path(),
1628            true,
1629            "main",
1630            None,
1631            crate::RefStorageFormat::Files,
1632        )
1633        .unwrap();
1634        assert!(matches!(
1635            normalize_repo_relative(&repo, dir.path(), "missing/file"),
1636            Err(Error::PathError(_))
1637        ));
1638    }
1639
1640    #[test]
1641    fn lexical_route_agrees_with_filesystem_route() {
1642        let dir = tempfile::tempdir().unwrap();
1643        let root = dir.path();
1644        crate::repo::init_repository(root, false, "main", None, crate::RefStorageFormat::Files)
1645            .unwrap();
1646        let options = crate::environment::RepositoryOptions::empty().with_work_tree(root);
1647        let repo = Repository::open(&root.join(".git"), &options).unwrap();
1648        let root = repo.work_tree().unwrap().to_path_buf();
1649        let inputs = [
1650            "",
1651            ".",
1652            "./",
1653            "a",
1654            "a/",
1655            "a//b",
1656            "./a/./b/",
1657            "a/../b",
1658            "a/b/../../c",
1659            "..",
1660            "../",
1661            "../x",
1662            "../../x/y/",
1663            "x/../../y",
1664            "café/new.txt",
1665            "...",
1666            ".hidden/..x",
1667        ];
1668        for cwd in [root.clone(), root.join("sub"), root.join("sub/deeper")] {
1669            let normalizer = RepoPathNormalizer::new(&repo, &cwd).unwrap();
1670            assert!(normalizer.cwd_rel.is_some());
1671            for input in inputs {
1672                let general = normalizer.normalize_via_filesystem_paths(input);
1673                let lexical = normalizer.normalize_lexically(input);
1674                match (&general, lexical) {
1675                    (Ok(expected), Some(got)) => assert_eq!(&got, expected, "{cwd:?} {input}"),
1676                    (_, None) => {}
1677                    (Err(e), Some(got)) => panic!("{cwd:?} {input}: lexical {got}, general {e}"),
1678                }
1679                assert_eq!(
1680                    normalizer.normalize(input).ok(),
1681                    general.ok(),
1682                    "{cwd:?} {input}"
1683                );
1684            }
1685        }
1686        let from_root = RepoPathNormalizer::new(&repo, &root).unwrap();
1687        assert_eq!(from_root.normalize("a/../b/").unwrap(), "b/");
1688        assert_eq!(from_root.normalize(":(glob)x").unwrap(), ":(glob)x");
1689        assert!(from_root.normalize("../x").is_err());
1690        let absolute = root.join("sub/f");
1691        assert_eq!(
1692            from_root.normalize(absolute.to_str().unwrap()).unwrap(),
1693            "sub/f"
1694        );
1695
1696        let outside = dir.path().parent().unwrap();
1697        let normalizer = RepoPathNormalizer::new(&repo, outside).unwrap();
1698        assert!(normalizer.cwd_rel.is_none());
1699        let name = root.file_name().unwrap().to_str().unwrap();
1700        assert_eq!(normalizer.normalize(&format!("{name}/x/")).unwrap(), "x/");
1701        assert!(normalizer.normalize("elsewhere").is_err());
1702    }
1703}
1704
1705#[cfg(test)]
1706mod per_directory_load_tests {
1707    use super::*;
1708
1709    #[test]
1710    fn known_directory_reads_its_exclude_file_on_first_query() {
1711        let dir = tempfile::tempdir().unwrap();
1712        let root = dir.path();
1713        let repo =
1714            crate::repo::init_repository(root, false, "main", None, crate::RefStorageFormat::Files)
1715                .unwrap();
1716        fs::create_dir_all(root.join("d/sub")).unwrap();
1717        fs::write(root.join("d/.gitignore"), "*.tmp\n").unwrap();
1718
1719        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
1720        matcher.note_directory_known("d");
1721        matcher.note_directory_known("d/sub");
1722        assert!(
1723            matcher.gitignore_cache.is_empty(),
1724            "entering a directory must not read its exclude file"
1725        );
1726        assert!(matcher.known_worktree_directories.contains("d/sub"));
1727
1728        assert!(matcher
1729            .is_path_ignored(&repo, None, "d/sub/x.tmp", false)
1730            .unwrap());
1731        assert!(!matcher
1732            .is_path_ignored(&repo, None, "d/sub/x.txt", false)
1733            .unwrap());
1734        assert!(matcher.gitignore_cache.contains_key("d"));
1735        assert!(matcher.gitignore_cache.contains_key("d/sub"));
1736    }
1737}
1738
1739#[cfg(test)]
1740mod sparse_checkout_tests {
1741    use super::*;
1742
1743    #[test]
1744    fn non_cone_default_init_patterns() {
1745        let lines = vec!["/*".into(), "!/*/".into()];
1746        assert!(path_in_sparse_checkout("a", &lines, None));
1747        assert!(!path_in_sparse_checkout("folder1/a", &lines, None));
1748        let wt = std::env::temp_dir().join("grit-sparse-wt-test");
1749        let _ = std::fs::create_dir_all(wt.join("folder1"));
1750        let _ = std::fs::write(wt.join("a"), b"x");
1751        assert!(!path_in_sparse_checkout("folder1/a", &lines, Some(&wt)));
1752        assert!(!path_in_sparse_checkout("folder1", &lines, Some(&wt)));
1753    }
1754
1755    #[test]
1756    fn unanchored_glob_matches_path_components() {
1757        let lines = vec!["/*".into(), "!/*/".into(), "*folder*".into()];
1758        assert!(path_in_sparse_checkout("folder1/a", &lines, None));
1759        assert!(path_in_sparse_checkout("folder2/a", &lines, None));
1760        assert!(!path_in_sparse_checkout("deep/a", &lines, None));
1761    }
1762
1763    #[test]
1764    fn non_cone_src_nested_under_included_dir_with_negated_slash_slash() {
1765        let lines = vec!["/*".into(), "!/*/".into(), "/src/".into()];
1766        assert!(path_in_sparse_checkout("a.txt", &lines, None));
1767        assert!(path_in_sparse_checkout("src/z", &lines, None));
1768        assert!(path_in_sparse_checkout("src/deep/w", &lines, None));
1769        assert!(!path_in_sparse_checkout("d1/x", &lines, None));
1770        assert!(!path_in_sparse_checkout("d1/sub/y", &lines, None));
1771    }
1772
1773    #[test]
1774    fn unanchored_star_with_negated_other_includes_other_slash_q() {
1775        let lines = vec!["*".into(), "!other".into()];
1776        assert!(path_in_sparse_checkout("other/q", &lines, None));
1777        assert!(!path_in_sparse_checkout("other", &lines, None));
1778    }
1779}
1780
1781#[cfg(test)]
1782mod gitignore_glob_tests {
1783    use super::*;
1784
1785    fn pathname_pattern_matches(pattern: &str, text: &str) -> bool {
1786        pattern_matches_kind(
1787            &classify_ignore_pattern(pattern),
1788            pattern,
1789            text,
1790            true,
1791            false,
1792        )
1793    }
1794
1795    fn rule(line: &str, base_dir: &str) -> IgnoreRule {
1796        parse_rule_line(line, 1, IgnoreSource::InfoExclude, base_dir).unwrap()
1797    }
1798
1799    #[test]
1800    fn lines_lose_at_most_one_trailing_cr() {
1801        let rules = |content: &str| {
1802            parse_gitignore_content(content, IgnoreSource::InfoExclude, "", &mut Vec::new())
1803                .unwrap()
1804        };
1805        let [crlf] = rules("a\r\n").try_into().unwrap();
1806        assert!(rule_matches(&crlf, "a", false, false));
1807        let [double_cr] = rules("a\r\r\n").try_into().unwrap();
1808        assert!(rule_matches(&double_cr, "a\r", false, false));
1809        assert!(!rule_matches(&double_cr, "a", false, false));
1810        let [leading_cr] = rules("\ra\n").try_into().unwrap();
1811        assert!(rule_matches(&leading_cr, "\ra", false, false));
1812        assert!(rules("a\nb").len() == 2);
1813    }
1814
1815    #[test]
1816    fn trailing_spaces_are_trimmed_unless_escaped() {
1817        for (line, expected) in [
1818            ("a  ", "a"),
1819            ("a\\ ", "a\\ "),
1820            ("a\\  ", "a\\ "),
1821            ("a\\", "a\\"),
1822            ("é b ", "é b"),
1823            ("   ", ""),
1824            ("a \\\\ ", "a \\\\"),
1825        ] {
1826            assert_eq!(trim_trailing_spaces_git(line), expected, "{line:?}");
1827        }
1828        for empty in ["!", "/", "!/", "//", "# x", " ", "\r"] {
1829            let parsed = parse_rule_line(empty, 1, IgnoreSource::InfoExclude, "");
1830            assert!(parsed.is_none(), "{empty:?}");
1831        }
1832        let r = rule("!/a/b/ ", "");
1833        assert!(r.negative && r.anchored && r.directory_only && r.has_slash);
1834        assert_eq!(
1835            (r.pattern_text.as_str(), r.body.as_str()),
1836            ("!/a/b/", "a/b")
1837        );
1838    }
1839
1840    #[test]
1841    fn every_pattern_kind_folds_ascii_case_only_with_ignore_case() {
1842        for (pattern, kind, path) in [
1843            ("README", "literal", "readme"),
1844            ("Target*", "prefix", "target-debug"),
1845            ("*.LOG", "suffix", "debug.log"),
1846            ("[Dd]EBUG?.txt", "wild", "debug1.TXT"),
1847        ] {
1848            let r = rule(pattern, "");
1849            assert!(!rule_matches(&r, path, false, false), "{kind}: {pattern}");
1850            assert!(rule_matches(&r, path, false, true), "{kind}: {pattern}");
1851            assert!(rule_matches(&r, &format!("sub/{path}"), false, true));
1852        }
1853        assert!(!rule_matches(&rule("*.LOG", ""), "x.lag", false, true));
1854        assert!(!rule_matches(&rule("Target*", ""), "tar", false, true));
1855        assert!(!rule_matches(&rule("*.LOG", ""), "LOG", false, true));
1856        // Git folds ASCII only; non-ASCII letters keep their case.
1857        assert!(!rule_matches(&rule("*É", ""), "é", false, true));
1858        assert!(!rule_matches(&rule("Über", ""), "über", false, true));
1859        assert!(rule_matches(&rule("Über", ""), "ÜBER", false, true));
1860    }
1861
1862    #[test]
1863    fn directory_and_anchored_rules_fold_case() {
1864        let dir_only = rule("Build/", "");
1865        assert!(rule_matches(&dir_only, "build", true, true));
1866        assert!(rule_matches(&dir_only, "src/BUILD", true, true));
1867        assert!(!rule_matches(&dir_only, "build", true, false));
1868        assert!(!rule_matches(&dir_only, "build", false, true));
1869        let anchored = rule("/Docs/*.PDF", "");
1870        assert!(rule_matches(&anchored, "docs/manual.pdf", false, true));
1871        assert!(!rule_matches(&anchored, "docs/sub/manual.pdf", false, true));
1872        assert!(!rule_matches(&anchored, "docs/manual.pdf", false, false));
1873    }
1874
1875    #[test]
1876    fn nested_gitignore_base_dir_folds_case() {
1877        let nested = rule("*.tmp", "Sub/Dir");
1878        assert!(rule_matches(&nested, "Sub/Dir/a.tmp", false, false));
1879        assert!(!rule_matches(&nested, "sub/dir/a.tmp", false, false));
1880        assert!(rule_matches(&nested, "sub/dir/a.tmp", false, true));
1881        assert!(!rule_matches(&nested, "sub/dirx/a.tmp", false, true));
1882        assert!(!rule_matches(&nested, "sub/di", false, true));
1883        assert_eq!(strip_base("Sub", "sub", true), Some(""));
1884        assert_eq!(strip_base("Sub", "SUB/x", true), Some("x"));
1885        assert_eq!(strip_base("Sub", "subway/x", true), None);
1886        assert_eq!(strip_base("Sub", "sub/x", false), None);
1887    }
1888
1889    #[test]
1890    fn precompiled_literal_prefix_suffix_patterns() {
1891        assert!(matches!(
1892            classify_ignore_pattern("build"),
1893            IgnorePatternKind::Literal(_)
1894        ));
1895        assert!(matches!(
1896            classify_ignore_pattern("target*"),
1897            IgnorePatternKind::Prefix(_)
1898        ));
1899        assert!(matches!(
1900            classify_ignore_pattern("*.o"),
1901            IgnorePatternKind::Suffix(_)
1902        ));
1903        assert!(matches!(
1904            classify_ignore_pattern("a*b"),
1905            IgnorePatternKind::Wild
1906        ));
1907        assert!(matches!(
1908            classify_ignore_pattern(r"foo\ bar"),
1909            IgnorePatternKind::Wild
1910        ));
1911        assert!(matches!(
1912            classify_ignore_pattern("foo/*"),
1913            IgnorePatternKind::Wild
1914        ));
1915        assert!(!pathname_pattern_matches("foo/*", "foo/bar/baz"));
1916        assert!(rule_body_matches(
1917            &IgnoreRule {
1918                source: IgnoreSource::InfoExclude,
1919                line_number: 1,
1920                pattern_text: "*.log".into(),
1921                negative: false,
1922                directory_only: false,
1923                anchored: false,
1924                has_slash: false,
1925                body: "*.log".into(),
1926                base_dir: String::new(),
1927                match_kind: IgnorePatternKind::Suffix(".log".into()),
1928            },
1929            "app.log",
1930            false,
1931            false
1932        ));
1933    }
1934
1935    #[test]
1936    fn dir_star_extension_matches_nested_path() {
1937        // Slash in pattern → WM_PATHNAME: `*` does not cross `/` (gitignore(5)).
1938        assert!(!pathname_pattern_matches("doc/*.pdf", "doc/sub/manual.pdf"));
1939        assert!(pathname_pattern_matches("doc/*.pdf", "doc/manual.pdf"));
1940        assert!(!pathname_pattern_matches("doc/*.pdf", "other/manual.pdf"));
1941        assert!(pathname_pattern_matches(
1942            "doc/**/*.pdf",
1943            "doc/sub/manual.pdf"
1944        ));
1945    }
1946
1947    #[test]
1948    fn dir_star_extension_unexpanded_when_parent_has_glob() {
1949        assert!(!pathname_pattern_matches("*/foo/*.pdf", "a/foo/sub/x.pdf"));
1950    }
1951
1952    #[test]
1953    fn nested_dir_star_extension() {
1954        assert!(!pathname_pattern_matches("foo/bar/*.c", "foo/bar/baz/x.c"));
1955    }
1956}
1957
1958#[cfg(all(test, unix))]
1959mod per_directory_gitignore_tests {
1960    use super::*;
1961    use crate::repo::init_repository;
1962    use crate::RefStorageFormat;
1963    use std::os::unix::ffi::OsStringExt;
1964    use std::os::unix::fs::symlink;
1965    use tempfile::TempDir;
1966
1967    fn ignored_with_warnings(repo: &Repository, path: &str) -> (bool, Vec<String>) {
1968        let mut matcher = IgnoreMatcher::from_repository(repo).unwrap();
1969        let ignored = matcher.is_path_ignored(repo, None, path, false).unwrap();
1970        (ignored, matcher.take_warnings())
1971    }
1972
1973    #[test]
1974    fn symlinked_gitignore_is_skipped_with_warning_and_plain_one_applies() {
1975        let dir = TempDir::new().unwrap();
1976        let root = dir.path();
1977        let repo = init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
1978        fs::create_dir_all(root.join("plain")).unwrap();
1979        fs::create_dir_all(root.join("to_file")).unwrap();
1980        fs::create_dir_all(root.join("to_dir")).unwrap();
1981        fs::create_dir_all(root.join("none")).unwrap();
1982        fs::write(root.join("plain/.gitignore"), "*.log\n").unwrap();
1983        fs::write(root.join("rules"), "*.log\n").unwrap();
1984        symlink(root.join("rules"), root.join("to_file/.gitignore")).unwrap();
1985        symlink(root.join("plain"), root.join("to_dir/.gitignore")).unwrap();
1986        fs::create_dir_all(root.join("dangling")).unwrap();
1987        symlink(root.join("no-such-rules"), root.join("dangling/.gitignore")).unwrap();
1988        fs::create_dir_all(root.join("to_fifo")).unwrap();
1989        let fifo = std::ffi::CString::new(root.join("fifo").into_os_string().into_vec()).unwrap();
1990        // SAFETY: `fifo` is a NUL-terminated path that outlives the call.
1991        assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o644) }, 0);
1992        symlink(root.join("fifo"), root.join("to_fifo/.gitignore")).unwrap();
1993
1994        assert_eq!(ignored_with_warnings(&repo, "plain/a.log"), (true, vec![]));
1995        assert_eq!(ignored_with_warnings(&repo, "none/a.log"), (false, vec![]));
1996        let (done_tx, done_rx) = std::sync::mpsc::channel();
1997        let fifo_root = root.to_path_buf();
1998        std::thread::spawn(move || {
1999            let options = crate::environment::RepositoryOptions::empty().with_work_tree(&fifo_root);
2000            let repo = Repository::open(&fifo_root.join(".git"), &options).unwrap();
2001            let _ = done_tx.send(ignored_with_warnings(&repo, "to_fifo/a.log"));
2002        });
2003        let (ignored, warnings) = done_rx
2004            .recv_timeout(std::time::Duration::from_secs(5))
2005            .expect("a .gitignore symlink to a FIFO must be rejected without opening it");
2006        assert!(!ignored);
2007        assert_eq!(warnings.len(), 1, "{warnings:?}");
2008        for dir in ["to_file", "to_dir", "dangling"] {
2009            let (ignored, warnings) = ignored_with_warnings(&repo, &format!("{dir}/a.log"));
2010            assert!(!ignored, "{dir}: symlinked .gitignore must not apply");
2011            assert_eq!(warnings.len(), 1, "{dir}: {warnings:?}");
2012            assert!(warnings[0].contains(&format!("{dir}/.gitignore")));
2013        }
2014    }
2015}
2016
2017#[cfg(test)]
2018mod ignore_case_matcher_tests {
2019    use super::*;
2020    use crate::repo::init_repository;
2021    use crate::RefStorageFormat;
2022    use tempfile::TempDir;
2023
2024    fn issue_repo(ignore_case: bool) -> (TempDir, Repository) {
2025        let dir = TempDir::new().unwrap();
2026        let root = dir.path();
2027        init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
2028        let mut config = fs::read_to_string(root.join(".git/config")).unwrap();
2029        config.push_str(&format!("[core]\n\tignorecase = {ignore_case}\n"));
2030        fs::write(root.join(".git/config"), config).unwrap();
2031        fs::write(root.join(".gitignore"), "Build/\n*.LOG\n!keep.log\n").unwrap();
2032        fs::create_dir_all(root.join("build")).unwrap();
2033        for file in ["build/x.o", "debug.log", "keep.log", "README"] {
2034            fs::write(root.join(file), "x\n").unwrap();
2035        }
2036        let options = crate::environment::RepositoryOptions::empty().with_work_tree(root);
2037        let repo = Repository::open(&root.join(".git"), &options).unwrap();
2038        (dir, repo)
2039    }
2040
2041    fn ignored(matcher: &mut IgnoreMatcher, repo: &Repository, path: &str, is_dir: bool) -> bool {
2042        let fast = matcher.is_path_ignored(repo, None, path, is_dir).unwrap();
2043        let (verbose, _) = matcher.check_path(repo, None, path, is_dir).unwrap();
2044        assert_eq!(fast, verbose, "{path}");
2045        fast
2046    }
2047
2048    #[test]
2049    fn from_repository_honors_core_ignorecase() {
2050        let (_dir, repo) = issue_repo(true);
2051        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2052        assert!(matcher.ignore_case());
2053        assert!(ignored(&mut matcher, &repo, "build", true));
2054        assert!(ignored(&mut matcher, &repo, "build/x.o", false));
2055        assert!(ignored(&mut matcher, &repo, "debug.log", false));
2056        assert!(!ignored(&mut matcher, &repo, "keep.log", false));
2057        assert!(!ignored(&mut matcher, &repo, "KEEP.LOG", false));
2058        assert!(!ignored(&mut matcher, &repo, "README", false));
2059        let (_, matched) = matcher.check_path(&repo, None, "build/x.o", false).unwrap();
2060        assert_eq!(matched.unwrap().pattern_text, "Build/");
2061    }
2062
2063    #[test]
2064    fn case_sensitive_by_default_and_overridable() {
2065        let (_dir, repo) = issue_repo(false);
2066        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2067        assert!(!matcher.ignore_case());
2068        assert!(!ignored(&mut matcher, &repo, "build/x.o", false));
2069        assert!(!ignored(&mut matcher, &repo, "debug.log", false));
2070        assert!(ignored(&mut matcher, &repo, "Build/x.o", false));
2071
2072        let mut folded = matcher.with_ignore_case(true);
2073        assert!(ignored(&mut folded, &repo, "build/x.o", false));
2074        assert!(ignored(&mut folded, &repo, "debug.log", false));
2075        assert!(!IgnoreMatcher::default().ignore_case());
2076    }
2077}
2078
2079#[cfg(test)]
2080mod index_lookup_scaling_tests {
2081    use super::*;
2082    use crate::index::{index_path_cmp, Index, IndexEntry, MODE_REGULAR};
2083    use crate::objects::ObjectId;
2084    use crate::repo::init_repository;
2085    use crate::RefStorageFormat;
2086    use tempfile::TempDir;
2087
2088    fn dummy_oid() -> ObjectId {
2089        ObjectId::from_bytes(&[0u8; 20]).unwrap()
2090    }
2091
2092    fn make_entry(path: &str) -> IndexEntry {
2093        IndexEntry {
2094            ctime_sec: 0,
2095            ctime_nsec: 0,
2096            mtime_sec: 0,
2097            mtime_nsec: 0,
2098            dev: 0,
2099            ino: 0,
2100            mode: MODE_REGULAR,
2101            uid: 0,
2102            gid: 0,
2103            size: 0,
2104            oid: dummy_oid(),
2105            flags: path.len().min(0xFFF) as u16,
2106            flags_extended: None,
2107            path: path.as_bytes().to_vec(),
2108            base_index_pos: 0,
2109        }
2110    }
2111
2112    #[test]
2113    fn tracked_check_path_index_lookups_sublinear_in_index_size() {
2114        const N: usize = 100_000;
2115        const M: usize = 1_000;
2116        let dir = TempDir::new().unwrap();
2117        let repo =
2118            init_repository(dir.path(), false, "main", None, RefStorageFormat::Files).unwrap();
2119        let mut index = Index::new();
2120        for i in 0..N {
2121            index.add_or_replace(make_entry(&format!("tracked/{i:06}.txt")));
2122        }
2123        let mut matcher = IgnoreMatcher::default();
2124        index_path_cmp::reset();
2125        for i in 0..M {
2126            let path = format!("untracked/{i:06}.txt");
2127            let (ignored, _) = matcher
2128                .check_path(&repo, Some(&index), &path, false)
2129                .unwrap();
2130            assert!(!ignored, "untracked path must not be ignored");
2131        }
2132        let cmps = index_path_cmp::take();
2133        let log_n = (N as f64).log2().ceil() as usize;
2134        let bound = 100 * M * log_n;
2135        assert!(
2136            cmps < bound,
2137            "index path comparisons {cmps} should stay under {bound} (N={N}, M={M})"
2138        );
2139    }
2140}
2141
2142#[cfg(test)]
2143mod pattern_list_tests {
2144    use super::IgnorePatternList;
2145
2146    fn ignored(content: &str, path: &str, is_dir: bool) -> bool {
2147        IgnorePatternList::parse(content.as_bytes(), "")
2148            .is_ignored(path, is_dir, false)
2149            .unwrap_or(false)
2150    }
2151
2152    #[test]
2153    fn basename_patterns_match_last_component_only() {
2154        assert!(ignored("foo\n", "foo", false));
2155        assert!(ignored("foo\n", "dir/foo", false));
2156        assert!(!ignored("foo\n", "foo/bar", false));
2157        assert!(ignored("*.o\n", "a/b/c.o", false));
2158    }
2159
2160    #[test]
2161    fn slash_patterns_match_whole_path() {
2162        assert!(ignored("/foo\n", "foo", false));
2163        assert!(!ignored("/foo\n", "dir/foo", false));
2164        assert!(ignored("dir/foo\n", "dir/foo", false));
2165        assert!(!ignored("dir/foo\n", "x/dir/foo", false));
2166        assert!(ignored("dir/*.o\n", "dir/a.o", false));
2167        assert!(!ignored("dir/*.o\n", "dir/sub/a.o", false));
2168        assert!(ignored("**/foo\n", "a/b/foo", false));
2169        assert!(ignored("a/**/b\n", "a/x/y/b", false));
2170    }
2171
2172    #[test]
2173    fn directory_only_patterns_need_a_directory() {
2174        assert!(ignored("foo/\n", "foo", true));
2175        assert!(!ignored("foo/\n", "foo", false));
2176        assert!(!ignored("foo/\n", "foo/bar", false));
2177    }
2178
2179    #[test]
2180    fn last_matching_pattern_wins() {
2181        let list = IgnorePatternList::parse(b"*.log\n!keep.log\n", "");
2182        assert_eq!(list.is_ignored("a.log", false, false), Some(true));
2183        assert_eq!(list.is_ignored("keep.log", false, false), Some(false));
2184        assert_eq!(list.is_ignored("a.txt", false, false), None);
2185        let m = list.last_match("keep.log", false, false).unwrap();
2186        assert_eq!((m.line_number, m.pattern_text.as_str()), (2, "!keep.log"));
2187    }
2188
2189    #[test]
2190    fn comments_blank_lines_and_trailing_spaces() {
2191        let list = IgnorePatternList::parse(b"\xef\xbb\xbf# comment\n\nfoo  \nbar\\ \n\\#x\n", "");
2192        assert_eq!(list.len(), 3);
2193        assert!(ignored("foo  \n", "foo", false));
2194        assert!(list.is_ignored("bar ", false, false).unwrap());
2195        assert!(list.is_ignored("#x", false, false).unwrap());
2196    }
2197
2198    #[test]
2199    fn base_directory_scopes_patterns() {
2200        let list = IgnorePatternList::parse(b"foo\n/bar\n", "sub");
2201        assert_eq!(list.is_ignored("sub/foo", false, false), Some(true));
2202        assert_eq!(list.is_ignored("sub/x/foo", false, false), Some(true));
2203        assert_eq!(list.is_ignored("sub/bar", false, false), Some(true));
2204        assert_eq!(list.is_ignored("sub/x/bar", false, false), None);
2205        assert_eq!(list.is_ignored("foo", false, false), None);
2206        assert_eq!(list.is_ignored("subfoo", false, false), None);
2207    }
2208
2209    #[test]
2210    fn ignore_case() {
2211        let list = IgnorePatternList::parse(b"*.LOG\nBuild/\n", "");
2212        assert_eq!(list.is_ignored("debug.log", false, false), None);
2213        assert_eq!(list.is_ignored("debug.log", false, true), Some(true));
2214        assert_eq!(list.is_ignored("build", true, true), Some(true));
2215    }
2216}
2217
2218#[cfg(test)]
2219mod dir_state_cache_tests {
2220    use super::*;
2221    use crate::repo::init_repository;
2222    use crate::RefStorageFormat;
2223    use tempfile::TempDir;
2224
2225    fn repo_with(files: &[(&str, &str)]) -> (TempDir, Repository) {
2226        let dir = TempDir::new().unwrap();
2227        let root = dir.path();
2228        init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
2229        for (path, content) in files {
2230            let path = root.join(path);
2231            fs::create_dir_all(path.parent().unwrap()).unwrap();
2232            fs::write(path, content).unwrap();
2233        }
2234        let options = crate::environment::RepositoryOptions::empty().with_work_tree(root);
2235        let repo = Repository::open(&root.join(".git"), &options).unwrap();
2236        (dir, repo)
2237    }
2238
2239    #[test]
2240    fn each_directory_state_and_exclude_file_is_built_once() {
2241        let (_dir, repo) = repo_with(&[
2242            (".gitignore", "*.o\n"),
2243            ("a/x/.gitignore", "*.tmp\n"),
2244            ("a/y/.gitignore", "*.bak\n"),
2245        ]);
2246        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2247        for round in 0..3 {
2248            for (path, expected) in [
2249                ("a/x/f.tmp", true),
2250                ("a/y/f.tmp", false),
2251                ("a/y/f.bak", true),
2252                ("a/x/f.o", true),
2253                ("a/x/f.rs", false),
2254                ("top.o", true),
2255            ] {
2256                let (ignored, _) = matcher.check_path(&repo, None, path, false).unwrap();
2257                assert_eq!(ignored, expected, "round {round}: {path}");
2258                assert_eq!(
2259                    matcher.is_path_ignored(&repo, None, path, false).unwrap(),
2260                    expected
2261                );
2262            }
2263        }
2264        // Root, `a`, `a/x` and `a/y`, each stat'd, read and probed once across all rounds.
2265        assert_eq!(matcher.dir_states.len(), 4);
2266        assert_eq!(matcher.rule_sets.len(), 4);
2267        let x = &matcher.dir_states[matcher.dir_state_ids["a/x"]];
2268        assert_eq!(x.stack.len(), 3);
2269        assert!(x.extendable && x.excluded.is_none());
2270    }
2271
2272    #[test]
2273    fn excluded_directory_is_recorded_on_its_descendants_states() {
2274        let (_dir, repo) = repo_with(&[(".gitignore", "build/\n!build/keep.txt\n")]);
2275        fs::create_dir_all(repo.work_tree().unwrap().join("build/deep")).unwrap();
2276        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2277        for path in ["build/keep.txt", "build/deep/a.c"] {
2278            let (ignored, matched) = matcher.check_path(&repo, None, path, false).unwrap();
2279            assert!(ignored, "{path}");
2280            assert_eq!(matched.unwrap().pattern_text, "build/");
2281        }
2282        assert!(matcher.dir_states[matcher.dir_state_ids["build"]]
2283            .excluded
2284            .is_some());
2285        assert!(matcher.dir_states[matcher.dir_state_ids["build/deep"]]
2286            .excluded
2287            .is_some());
2288        assert!(
2289            !matcher
2290                .check_path(&repo, None, "keep.txt", false)
2291                .unwrap()
2292                .0
2293        );
2294    }
2295
2296    #[test]
2297    fn a_file_ancestor_ends_the_per_directory_stack() {
2298        let (_dir, repo) = repo_with(&[(".gitignore", "*.o\n"), ("plain", "x\n")]);
2299        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2300        assert!(
2301            matcher
2302                .check_path(&repo, None, "plain/sub/x.o", false)
2303                .unwrap()
2304                .0
2305        );
2306        let plain = &matcher.dir_states[matcher.dir_state_ids["plain"]];
2307        assert_eq!(plain.stack.len(), 1);
2308        assert!(!plain.extendable);
2309        assert_eq!(
2310            matcher.dir_states[matcher.dir_state_ids["plain/sub"]]
2311                .stack
2312                .len(),
2313            1
2314        );
2315        assert!(!matcher.gitignore_cache.contains_key("plain"));
2316    }
2317
2318    #[test]
2319    fn batch_check_skips_directory_stats_only_when_the_path_stat_allows_it() {
2320        let (_dir, repo) = repo_with(&[
2321            (".gitignore", "*.o\n"),
2322            ("plain", "x\n"),
2323            ("real/.gitignore", "*.tmp\n"),
2324        ]);
2325        let index = Index::new();
2326        let paths = [
2327            "plain/sub/x.o",
2328            "plain/x.tmp",
2329            "real/x.tmp",
2330            "missing/deeper/x.tmp",
2331            "real/",
2332        ]
2333        .map(str::to_owned);
2334        let checks = repo.ignore_check(&paths, &index).unwrap();
2335        let ignored: Vec<bool> = checks.iter().map(|c| c.ignored).collect();
2336        assert_eq!(ignored, [true, false, true, false, false]);
2337        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2338        let wt = repo.work_tree().unwrap();
2339        let mut scratch = PathBuf::new();
2340        assert_eq!(
2341            probe_query_path(Some(wt), "real", &mut scratch),
2342            (true, true)
2343        );
2344        assert_eq!(
2345            probe_query_path(Some(wt), "missing/x", &mut scratch),
2346            (false, cfg!(unix))
2347        );
2348        assert_eq!(
2349            probe_query_path(Some(wt), "plain/x", &mut scratch),
2350            (false, false)
2351        );
2352        assert_eq!(
2353            probe_query_path(Some(wt), "real/", &mut scratch),
2354            (true, false)
2355        );
2356        matcher
2357            .check_path_with_ancestors(&repo, None, "plain/x.tmp", false, false)
2358            .unwrap();
2359        assert!(!matcher.dir_states[matcher.dir_state_ids["plain"]].extendable);
2360    }
2361
2362    #[test]
2363    fn adding_pattern_sources_refreshes_cached_directory_states() {
2364        let (_dir, repo) = repo_with(&[("d/.gitignore", "*.tmp\n")]);
2365        let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2366        assert!(!matcher
2367            .is_path_ignored(&repo, None, "d/x.log", false)
2368            .unwrap());
2369        assert!(!matcher
2370            .is_path_ignored(&repo, None, "d/sub/x.c", false)
2371            .unwrap());
2372        matcher.add_cli_excludes(&["*.log".to_owned(), "sub".to_owned()]);
2373        assert!(matcher
2374            .is_path_ignored(&repo, None, "d/x.log", false)
2375            .unwrap());
2376        assert!(matcher
2377            .is_path_ignored(&repo, None, "d/sub/x.c", false)
2378            .unwrap());
2379
2380        let mut folded = matcher.with_ignore_case(true);
2381        assert!(folded
2382            .is_path_ignored(&repo, None, "d/X.TMP", false)
2383            .unwrap());
2384
2385        folded.set_per_directory_name(".other");
2386        assert!(!folded
2387            .is_path_ignored(&repo, None, "d/x.tmp", false)
2388            .unwrap());
2389    }
2390
2391    #[test]
2392    fn last_match_in_picks_the_last_matching_line_for_the_base_directory() {
2393        let source = IgnoreSource::PerDirectory("sub/.gitignore".into());
2394        let rules =
2395            parse_gitignore_content("*.log\n!keep.log\nkeep*\n", source, "sub", &mut Vec::new())
2396                .unwrap();
2397        let hit = |path| last_match_in("sub", &rules, path, false, false).map(|r| r.line_number);
2398        assert_eq!(hit("sub/a.log"), Some(1));
2399        assert_eq!(hit("sub/deep/keep.log"), Some(3));
2400        assert_eq!(hit("sub/keep.txt"), Some(3));
2401        assert_eq!(hit("other/a.log"), None);
2402        assert_eq!(hit("sub"), None);
2403        assert_eq!(hit("subx/a.log"), None);
2404        assert_eq!(
2405            last_match_in("", &[], "a.log", false, false).map(|r| r.line_number),
2406            None
2407        );
2408    }
2409}