Skip to main content

grit_lib/
pathspec.rs

1//! Git-compatible pathspec matching (magic tokens and global flags).
2//!
3//! Global flags are read from the same environment variables as Git:
4//! `GIT_LITERAL_PATHSPECS`, `GIT_GLOB_PATHSPECS`, `GIT_NOGLOB_PATHSPECS`,
5//! `GIT_ICASE_PATHSPECS`. The `grit` binary sets these from CLI flags such as
6//! `--literal-pathspecs` before dispatching subcommands.
7
8use std::borrow::Cow;
9
10use crate::crlf::path_has_gitattribute;
11use crate::crlf::AttrRule;
12use crate::error::{Error, Result as LibResult};
13use crate::precompose_config::pathspec_precompose_enabled;
14use crate::unicode_normalization::precompose_utf8_path;
15use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
16
17/// Returns the length of the leading literal segment before the first glob metacharacter,
18/// matching Git's `simple_length()` (`*` `?` `[` `\`) on bytes.
19#[must_use]
20pub fn simple_length(match_str: &str) -> usize {
21    let b = match_str.as_bytes();
22    let mut len = 0usize;
23    for &c in b {
24        if matches!(c, b'*' | b'?' | b'[' | b'\\') {
25            break;
26        }
27        len += 1;
28    }
29    len
30}
31
32/// Whether the pattern uses wildcards after Git pathspec escaping rules.
33#[must_use]
34pub fn has_glob_chars(s: &str) -> bool {
35    simple_length(s) < s.len()
36}
37
38/// Read pathspec entries from raw file bytes (stdin or file), matching Git's
39/// `--pathspec-from-file` / `--pathspec-file-nul` rules.
40///
41/// * **NUL mode:** entries are separated by `NUL`; each segment must not use
42///   C-style quoted lines (Git rejects quoted pathspecs in this mode).
43/// * **Line mode:** entries are separated by `LF`; optional `CR` before `LF`
44///   is stripped; optional trailing line without a final newline is included;
45///   double-quoted lines are C-unquoted (including octal escapes).
46pub fn parse_pathspecs_from_source(data: &[u8], nul_terminated: bool) -> LibResult<Vec<String>> {
47    if nul_terminated {
48        let mut out = Vec::new();
49        for chunk in data.split(|b| *b == 0) {
50            if chunk.is_empty() {
51                continue;
52            }
53            let s = String::from_utf8_lossy(chunk);
54            let t = s.trim();
55            if t.starts_with('"') {
56                return Err(Error::PathError(format!(
57                    "pathspec-from-file: line is not NUL terminated: {t}"
58                )));
59            }
60            out.push(t.to_string());
61        }
62        return Ok(out);
63    }
64
65    let text = String::from_utf8_lossy(data);
66    let mut out = Vec::new();
67    for raw in text.split_inclusive('\n') {
68        let line = raw.trim_end_matches('\n').trim_end_matches('\r');
69        if line.is_empty() {
70            continue;
71        }
72        if line.starts_with('"') && line.ends_with('"') && line.len() >= 2 {
73            out.push(unquote_c_style_pathspec_line(line)?);
74        } else {
75            out.push(line.to_string());
76        }
77    }
78    Ok(out)
79}
80
81/// Unquote a single `--pathspec-from-file` line that is wrapped in double quotes.
82fn unquote_c_style_pathspec_line(s: &str) -> LibResult<String> {
83    let bytes = s.as_bytes();
84    if bytes.first() != Some(&b'"') || bytes.last() != Some(&b'"') || bytes.len() < 2 {
85        return Err(Error::PathError(format!("invalid C-style quoting: {s}")));
86    }
87
88    let inner = &bytes[1..bytes.len() - 1];
89    let mut out = Vec::with_capacity(inner.len());
90    let mut i = 0;
91    while i < inner.len() {
92        if inner[i] != b'\\' {
93            out.push(inner[i]);
94            i += 1;
95            continue;
96        }
97        i += 1;
98        if i >= inner.len() {
99            return Err(Error::PathError(
100                "invalid escape at end of string".to_string(),
101            ));
102        }
103        match inner[i] {
104            b'\\' => out.push(b'\\'),
105            b'"' => out.push(b'"'),
106            b'a' => out.push(7),
107            b'b' => out.push(8),
108            b'f' => out.push(12),
109            b'n' => out.push(b'\n'),
110            b'r' => out.push(b'\r'),
111            b't' => out.push(b'\t'),
112            b'v' => out.push(11),
113            c if c.is_ascii_digit() => {
114                if i + 2 >= inner.len() {
115                    return Err(Error::PathError("truncated octal escape".to_string()));
116                }
117                let oct = std::str::from_utf8(&inner[i..i + 3])
118                    .map_err(|_| Error::PathError("invalid octal bytes".to_string()))?;
119                out.push(
120                    u8::from_str_radix(oct, 8)
121                        .map_err(|_| Error::PathError("invalid octal escape value".to_string()))?,
122                );
123                i += 2;
124            }
125            other => {
126                return Err(Error::PathError(format!(
127                    "invalid escape sequence \\{}",
128                    char::from(other)
129                )));
130            }
131        }
132        i += 1;
133    }
134    String::from_utf8(out).map_err(|_| Error::PathError("invalid UTF-8 in quoted pathspec".into()))
135}
136
137#[derive(Debug, Clone, Default)]
138struct PathspecMagic {
139    literal: bool,
140    glob: bool,
141    icase: bool,
142    exclude: bool,
143    /// `:(top)` / short `:/` — paths are relative to repo root.
144    top: bool,
145    prefix: Option<String>,
146    /// `:(attr:NAME)` — match paths that have gitattribute `NAME` set.
147    attr_name: Option<String>,
148}
149
150fn parse_maybe_bool(v: &str) -> Option<bool> {
151    let s = v.trim().to_ascii_lowercase();
152    match s.as_str() {
153        "true" | "yes" | "on" | "1" => Some(true),
154        "false" | "no" | "off" | "0" => Some(false),
155        _ => None,
156    }
157}
158
159fn git_env_bool(key: &str, default: bool) -> bool {
160    match std::env::var(key) {
161        Ok(v) => parse_maybe_bool(&v).unwrap_or(default),
162        Err(_) => default,
163    }
164}
165
166fn literal_global() -> bool {
167    git_env_bool("GIT_LITERAL_PATHSPECS", false)
168}
169
170/// Whether `GIT_LITERAL_PATHSPECS` is enabled (shell `*` and `?` are literal, not globs).
171#[must_use]
172pub fn literal_pathspecs_enabled() -> bool {
173    literal_global()
174}
175
176fn glob_global() -> bool {
177    git_env_bool("GIT_GLOB_PATHSPECS", false)
178}
179
180fn noglob_global() -> bool {
181    git_env_bool("GIT_NOGLOB_PATHSPECS", false)
182}
183
184fn icase_global() -> bool {
185    git_env_bool("GIT_ICASE_PATHSPECS", false)
186}
187
188/// Validates global pathspec environment flags the same way Git does.
189///
190/// Returns an error message suitable for `bail!` when flags are incompatible.
191pub fn validate_global_pathspec_flags() -> Result<(), String> {
192    let lit = literal_global();
193    let glob = glob_global();
194    let noglob = noglob_global();
195    let icase = icase_global();
196
197    if glob && noglob {
198        return Err("global 'glob' and 'noglob' pathspec settings are incompatible".to_string());
199    }
200    if lit && (glob || noglob || icase) {
201        return Err(
202            "global 'literal' pathspec setting is incompatible with all other global pathspec settings"
203                .to_string(),
204        );
205    }
206    Ok(())
207}
208
209fn parse_long_magic(rest_after_paren: &str) -> Option<(PathspecMagic, &str)> {
210    let close = rest_after_paren.find(')')?;
211    let magic_part = &rest_after_paren[..close];
212    let tail = &rest_after_paren[close + 1..];
213    let mut magic = PathspecMagic::default();
214    for raw in magic_part.split(',') {
215        let token = raw.trim();
216        if token.is_empty() {
217            continue;
218        }
219        if let Some(p) = token.strip_prefix("prefix:") {
220            magic.prefix = Some(p.to_string());
221            continue;
222        }
223        if let Some(name) = token.strip_prefix("attr:") {
224            if !name.is_empty() {
225                magic.attr_name = Some(name.to_string());
226            }
227            continue;
228        }
229        if token.eq_ignore_ascii_case("literal") {
230            magic.literal = true;
231        } else if token.eq_ignore_ascii_case("glob") {
232            magic.glob = true;
233        } else if token.eq_ignore_ascii_case("icase") {
234            magic.icase = true;
235        } else if token.eq_ignore_ascii_case("exclude") {
236            magic.exclude = true;
237        } else if token.eq_ignore_ascii_case("top") {
238            magic.top = true;
239        }
240    }
241    Some((magic, tail))
242}
243
244/// `elem` is the full pathspec beginning with `:` (short magic form, not `:(...)`).
245fn parse_short_magic(elem: &str) -> (PathspecMagic, &str) {
246    let bytes = elem.as_bytes();
247    let mut i = 1usize;
248    let mut magic = PathspecMagic::default();
249    while i < bytes.len() && bytes[i] != b':' {
250        let ch = bytes[i];
251        if ch == b'^' {
252            magic.exclude = true;
253            i += 1;
254            continue;
255        }
256        let is_magic = match ch {
257            b'!' => {
258                magic.exclude = true;
259                true
260            }
261            b'/' => {
262                magic.top = true;
263                true
264            } // short `:/` = top
265            _ => false,
266        };
267        if is_magic {
268            i += 1;
269            continue;
270        }
271        break;
272    }
273    if i < bytes.len() && bytes[i] == b':' {
274        i += 1;
275    }
276    (magic, &elem[i..])
277}
278
279/// Strip `:(magic)` / `:magic` prefix when not in literal-global mode.
280fn parse_element_magic(elem: &str) -> (PathspecMagic, &str) {
281    if !elem.starts_with(':') || literal_global() {
282        return (PathspecMagic::default(), elem);
283    }
284    if let Some(rest) = elem.strip_prefix(":(") {
285        return parse_long_magic(rest).unwrap_or((PathspecMagic::default(), elem));
286    }
287    parse_short_magic(elem)
288}
289
290fn combine_magic(element: PathspecMagic) -> PathspecMagic {
291    let mut m = element;
292    if literal_global() {
293        m.literal = true;
294    }
295    if glob_global() && !m.literal {
296        m.glob = true;
297    }
298    if icase_global() {
299        m.icase = true;
300    }
301    if noglob_global() && !m.glob {
302        m.literal = true;
303    }
304    m
305}
306
307fn strip_top_magic(mut pattern: &str) -> &str {
308    if let Some(r) = pattern.strip_prefix(":/") {
309        pattern = r;
310    }
311    pattern
312}
313
314/// Path prefix used for Bloom-filter lookups (`revision.c` `convert_pathspec_to_bloom_keyvec`).
315///
316/// `cwd_from_repo_root` is the path from the repository work tree to the process cwd, using `/`
317/// separators and no leading slash (empty string at repo root). Used for `:(top)` / `:/`.
318#[must_use]
319pub fn bloom_lookup_prefix_with_cwd(
320    spec: &str,
321    cwd_from_repo_root: Option<&str>,
322) -> Option<String> {
323    let (elem_magic, raw_pattern) = parse_element_magic(spec);
324    let magic = combine_magic(elem_magic);
325    if magic.exclude || magic.icase {
326        return None;
327    }
328    let pattern = strip_top_magic(raw_pattern);
329    if pattern.is_empty() {
330        return None;
331    }
332    let combined = if magic.top {
333        let cwd = cwd_from_repo_root.unwrap_or("").trim_end_matches('/');
334        if cwd.is_empty() {
335            pattern.to_string()
336        } else {
337            format!("{cwd}/{pattern}")
338        }
339    } else {
340        pattern.to_string()
341    };
342    let pattern = combined.as_str();
343    let mut len = simple_length(pattern);
344    if len != pattern.len() {
345        while len > 0 && pattern.as_bytes()[len - 1] != b'/' {
346            len -= 1;
347        }
348    }
349    while len > 0 && pattern.as_bytes()[len - 1] == b'/' {
350        len -= 1;
351    }
352    if len == 0 {
353        return None;
354    }
355    Some(combined[..len].to_string())
356}
357
358#[must_use]
359pub fn bloom_lookup_prefix(spec: &str) -> Option<String> {
360    bloom_lookup_prefix_with_cwd(spec, None)
361}
362
363/// Whether every pathspec can participate in Bloom precomputation (Git `forbid_bloom_filters`).
364#[must_use]
365pub fn pathspecs_allow_bloom(specs: &[String]) -> bool {
366    specs.iter().all(|s| {
367        !s.is_empty() && !pathspec_is_exclude(s) && bloom_lookup_prefix_with_cwd(s, None).is_some()
368    })
369}
370
371/// Whether `path` is included when Git applies a pathspec list with optional `:(exclude)` entries.
372///
373/// A path is rejected if any exclude pathspec matches it. When at least one non-exclude pathspec is
374/// present, the path must also match one of those positives (`OR` semantics).
375#[must_use]
376pub fn path_allowed_by_pathspec_list(specs: &[String], path: &str) -> bool {
377    let mut has_positive = false;
378    let mut positive_match = false;
379    for s in specs {
380        let (elem, raw_pattern) = parse_element_magic(s);
381        let magic = combine_magic(elem);
382        if magic.exclude {
383            if path_matches_pathspec_tail(raw_pattern, path, magic) {
384                return false;
385            }
386            continue;
387        }
388        has_positive = true;
389        if pathspec_matches(s, path) {
390            positive_match = true;
391        }
392    }
393    !has_positive || positive_match
394}
395
396/// True when `spec` matches `path` for pathspec bookkeeping (positive match or exclude hit).
397#[must_use]
398pub fn pathspec_contributes_match(spec: &str, path: &str) -> bool {
399    pathspec_matches(spec, path) || pathspec_exclude_matches(spec, path)
400}
401
402fn path_matches_pathspec_tail(raw_pattern: &str, path: &str, magic: PathspecMagic) -> bool {
403    if magic.literal && magic.glob {
404        return false;
405    }
406    let pattern = strip_top_magic(raw_pattern);
407    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
408        if !path.starts_with(prefix) {
409            return false;
410        }
411        &path[prefix.len()..]
412    } else {
413        path
414    };
415    pathspec_matches_tail(pattern, path_for_match, magic)
416}
417
418/// True if `path` is matched by `spec` (Git pathspec syntax, including magic and globals).
419///
420/// Same as [`matches_pathspec`] (default file context; exclude specs never match positively here).
421/// See [`matches_pathspec_list`].
422#[must_use]
423pub fn pathspec_matches(spec: &str, path: &str) -> bool {
424    matches_pathspec(spec, path)
425}
426
427/// Returns whether `spec` uses Git's exclude magic (`:(exclude)`, `:!`, `:^`, etc.).
428#[must_use]
429pub fn pathspec_is_exclude(spec: &str) -> bool {
430    let (elem_magic, _) = parse_element_magic(spec);
431    combine_magic(elem_magic).exclude
432}
433
434/// Whether tree-walking should recurse into directory `full_name` for pathspec `spec` without
435/// `-r` (Git `read_tree` / `show_recursive` “interesting” descent).
436///
437/// Exclude-only patterns never trigger descent alone.
438#[must_use]
439pub fn pathspec_wants_descent_into_tree(spec: &str, full_name: &str) -> bool {
440    if pathspec_is_exclude(spec) {
441        return false;
442    }
443    let (elem_magic, raw_pattern) = parse_element_magic(spec);
444    let magic = combine_magic(elem_magic);
445    if magic.exclude {
446        return false;
447    }
448    let pattern = strip_top_magic(raw_pattern);
449    let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
450    if pattern.is_empty() || pattern == "." {
451        return true;
452    }
453    let dir_prefix = format!("{full_name}/");
454    if pattern.starts_with(&dir_prefix) {
455        return true;
456    }
457    let probe = format!("{full_name}/.__grit_ls_tree_probe__");
458    matches_ls_tree_pathspec(spec, &probe, 0o100644, &[])
459}
460
461/// Like [`matches_pathspec_set_for_object`], but uses [`matches_ls_tree_pathspec`] for each
462/// element so `ls-files` / index filtering agrees with `ls-tree` on patterns such as `a[a]`.
463#[must_use]
464pub fn matches_pathspec_set_for_object_ls_tree(
465    specs: &[String],
466    path: &str,
467    mode: u32,
468    attr_rules: &[AttrRule],
469) -> bool {
470    if specs.is_empty() {
471        return true;
472    }
473    let mut positives: Vec<&str> = Vec::new();
474    let mut excludes: Vec<&str> = Vec::new();
475    for s in specs {
476        if pathspec_is_exclude(s) {
477            excludes.push(s.as_str());
478        } else {
479            positives.push(s.as_str());
480        }
481    }
482    let positive_ok = if positives.is_empty() {
483        true
484    } else {
485        positives
486            .iter()
487            .any(|s| matches_ls_tree_pathspec(s, path, mode, attr_rules))
488    };
489    if !positive_ok {
490        return false;
491    }
492    for ex in excludes {
493        if matches_ls_tree_pathspec(ex, path, mode, attr_rules) {
494            return false;
495        }
496    }
497    true
498}
499
500/// True if `path` matches the combined pathspec list: any positive spec (or all paths when there
501/// are only excludes, matching Git `parse_pathspec`), and not matched by any exclude spec.
502#[must_use]
503pub fn matches_pathspec_set_for_object(
504    specs: &[String],
505    path: &str,
506    mode: u32,
507    attr_rules: &[AttrRule],
508) -> bool {
509    if specs.is_empty() {
510        return true;
511    }
512    let mut positives: Vec<&str> = Vec::new();
513    let mut excludes: Vec<&str> = Vec::new();
514    for s in specs {
515        if pathspec_is_exclude(s) {
516            excludes.push(s.as_str());
517        } else {
518            positives.push(s.as_str());
519        }
520    }
521    let positive_ok = if positives.is_empty() {
522        true
523    } else {
524        positives
525            .iter()
526            .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
527    };
528    if !positive_ok {
529        return false;
530    }
531    for ex in excludes {
532        if matches_pathspec_for_object(ex, path, mode, attr_rules) {
533            return false;
534        }
535    }
536    true
537}
538
539/// True if `spec` uses `:(top)` or short `:/` (repo-root-relative) magic.
540#[must_use]
541pub fn pathspec_has_top(spec: &str) -> bool {
542    let (elem_magic, _) = parse_element_magic(spec);
543    combine_magic(elem_magic).top
544}
545
546fn pathspec_match_one_positive(path: &str, magic: PathspecMagic, raw_pattern: &str) -> bool {
547    if magic.literal && magic.glob {
548        return false;
549    }
550    let pattern = strip_top_magic(raw_pattern);
551    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
552        if !path.starts_with(prefix) {
553            return false;
554        }
555        &path[prefix.len()..]
556    } else {
557        path
558    };
559    pathspec_matches_tail(pattern, path_for_match, magic)
560}
561
562fn matches_pathspec_element_with_context(
563    spec: &str,
564    path: &str,
565    ctx: PathspecMatchContext,
566) -> bool {
567    let (elem_magic, raw_pattern) = parse_element_magic(spec);
568    let magic = combine_magic(elem_magic);
569    if magic.exclude {
570        return false;
571    }
572    if magic.literal && magic.glob {
573        return false;
574    }
575    if magic.attr_name.is_some() {
576        return pathspec_matches(spec, path);
577    }
578    if magic.literal || magic.glob || magic.icase {
579        return pathspec_matches(spec, path);
580    }
581    let pattern = strip_top_magic(raw_pattern);
582    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
583        if !path.starts_with(prefix) {
584            return false;
585        }
586        &path[prefix.len()..]
587    } else {
588        path
589    };
590    matches_pathspec_with_context(pattern, path_for_match, ctx)
591}
592
593fn pathspec_exclude_element_matches_with_context(
594    spec: &str,
595    path: &str,
596    ctx: PathspecMatchContext,
597) -> bool {
598    let (elem_magic, raw_pattern) = parse_element_magic(spec);
599    let mut magic = combine_magic(elem_magic);
600    if !magic.exclude {
601        return false;
602    }
603    magic.exclude = false;
604    if magic.literal && magic.glob {
605        return false;
606    }
607    if magic.attr_name.is_some() {
608        // Attribute pathspecs need `.gitattributes` context; use
609        // [`matches_pathspec_list_for_object`] for those.
610        return false;
611    }
612    if magic.literal || magic.glob || magic.icase {
613        return pathspec_match_one_positive(path, magic, raw_pattern);
614    }
615    let pattern = strip_top_magic(raw_pattern);
616    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
617        if !path.starts_with(prefix) {
618            return false;
619        }
620        &path[prefix.len()..]
621    } else {
622        path
623    };
624    matches_pathspec_with_context(pattern, path_for_match, ctx)
625}
626
627/// True if `path` is matched by an exclude pathspec's pattern. Returns `false` if `spec` is not
628/// an exclude pathspec.
629#[must_use]
630pub fn pathspec_exclude_matches(spec: &str, path: &str) -> bool {
631    pathspec_exclude_element_matches_with_context(spec, path, PathspecMatchContext::default())
632}
633
634/// When every pathspec is an exclude and none use `:(top)` / `:/`, Git prepends an implicit
635/// positive that matches only under the process cwd (relative to the work tree), not the whole
636/// repository (`PATHSPEC_PREFER_CWD` in `pathspec.c`). `cwd_from_repo_root` is that prefix
637/// without a trailing slash, or empty at the work tree root.
638#[must_use]
639pub fn extend_pathspec_list_implicit_cwd(
640    specs: &[String],
641    cwd_from_repo_root: Option<&str>,
642) -> Vec<String> {
643    if specs.is_empty() {
644        return specs.to_vec();
645    }
646    if !specs.iter().all(|s| pathspec_is_exclude(s)) {
647        return specs.to_vec();
648    }
649    let any_top = specs.iter().any(|s| pathspec_has_top(s));
650    if any_top {
651        return specs.to_vec();
652    }
653    let Some(cwd) = cwd_from_repo_root.map(str::trim).filter(|s| !s.is_empty()) else {
654        return specs.to_vec();
655    };
656    let cwd = cwd.trim_end_matches('/');
657    if cwd.is_empty() {
658        return specs.to_vec();
659    }
660    let mut out = Vec::with_capacity(specs.len() + 1);
661    out.push(format!("{cwd}/"));
662    out.extend_from_slice(specs);
663    out
664}
665
666/// Git `match_pathspec` semantics over a pathspec list: OR of positive specs minus OR of exclude
667/// specs. If every element is exclude-only, Git implicitly prepends `.` (match all); this
668/// function does the same.
669#[must_use]
670pub fn matches_pathspec_list(path: &str, specs: &[String]) -> bool {
671    matches_pathspec_list_with_context(path, specs, PathspecMatchContext::default())
672}
673
674/// Like [`matches_pathspec_list`], but uses `ctx` for non-magic pathspec elements (trailing `/`).
675#[must_use]
676pub fn matches_pathspec_list_with_context(
677    path: &str,
678    specs: &[String],
679    ctx: PathspecMatchContext,
680) -> bool {
681    if specs.is_empty() {
682        return true;
683    }
684    let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
685    let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
686    let positive = if positive_specs.is_empty() {
687        true
688    } else {
689        positive_specs
690            .iter()
691            .any(|s| matches_pathspec_element_with_context(s, path, ctx))
692    };
693    if !positive {
694        return false;
695    }
696    if !has_exclude {
697        return true;
698    }
699    let excluded = specs.iter().any(|s| {
700        pathspec_is_exclude(s) && pathspec_exclude_element_matches_with_context(s, path, ctx)
701    });
702    !excluded
703}
704
705/// `matches_pathspec_list` for tree/index objects with mode and `.gitattributes` rules.
706#[must_use]
707pub fn matches_pathspec_list_for_object(
708    path: &str,
709    mode: u32,
710    attr_rules: &[AttrRule],
711    specs: &[String],
712) -> bool {
713    if specs.is_empty() {
714        return true;
715    }
716    let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
717    let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
718    let positive = if positive_specs.is_empty() {
719        true
720    } else {
721        positive_specs
722            .iter()
723            .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
724    };
725    if !positive {
726        return false;
727    }
728    if !has_exclude {
729        return true;
730    }
731    let excluded = specs.iter().any(|s| {
732        pathspec_is_exclude(s) && matches_pathspec_exclude_for_object(s, path, mode, attr_rules)
733    });
734    !excluded
735}
736
737fn matches_pathspec_exclude_for_object(
738    spec: &str,
739    path: &str,
740    mode: u32,
741    attr_rules: &[AttrRule],
742) -> bool {
743    let (elem_magic, raw_pattern) = parse_element_magic(spec);
744    let mut magic = combine_magic(elem_magic);
745    if !magic.exclude {
746        return false;
747    }
748    magic.exclude = false;
749    if magic.literal && magic.glob {
750        return false;
751    }
752    let ctx = context_from_mode_bits(mode);
753    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
754    if let Some(ref attr) = magic.attr_name {
755        if !path_has_gitattribute(attr_rules, path, is_dir_for_attr, attr) {
756            return false;
757        }
758    }
759    let pattern = strip_top_magic(raw_pattern);
760    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
761        if !path.starts_with(prefix) {
762            return false;
763        }
764        &path[prefix.len()..]
765    } else {
766        path
767    };
768    if magic.literal || magic.glob || magic.icase {
769        pathspec_matches_tail(pattern, path_for_match, magic)
770    } else {
771        matches_pathspec_with_context(pattern, path_for_match, ctx)
772    }
773}
774
775fn pathspec_matches_tail(pattern: &str, path: &str, magic: PathspecMagic) -> bool {
776    if pattern.is_empty() {
777        return true;
778    }
779
780    let flags = if magic.icase { WM_CASEFOLD } else { 0 };
781
782    if magic.literal {
783        return literal_prefix_match(pattern, path);
784    }
785
786    let wm_flags = if magic.glob {
787        flags | WM_PATHNAME
788    } else {
789        flags
790    };
791
792    let pattern_bytes = pattern.as_bytes();
793    let path_bytes = path.as_bytes();
794    let simple = simple_length(pattern);
795
796    // Git `match_pathspec_item`: exact / directory prefix before `git_fnmatch`.
797    // Only when the pattern has no glob metacharacters (`simple_length` spans the whole pattern);
798    // otherwise a pattern like `a[a]` must not match children via `a[a]/` prefix (t6130 vs ls-tree).
799    if ps_str_eq(pattern, path, magic.icase) {
800        return true;
801    }
802    if simple == pattern.len() {
803        if let Some(prefix) = pattern.strip_suffix('/') {
804            if ps_str_eq(prefix, path, magic.icase) {
805                return true;
806            }
807            let prefix_slash = format!("{prefix}/");
808            if path_starts_with(path, &prefix_slash, magic.icase) {
809                return true;
810            }
811        } else {
812            let prefix_slash = format!("{pattern}/");
813            if path_starts_with(path, &prefix_slash, magic.icase) {
814                return true;
815            }
816        }
817    }
818
819    // `:(glob)**/*.txt` at repo root: Git matches `untracked.txt` (leading `**/` is optional).
820    if magic.glob && !path.contains('/') && pattern.starts_with("**/") {
821        if wildmatch(pattern_bytes, path_bytes, wm_flags) {
822            return true;
823        }
824        if let Some(suffix) = pattern.strip_prefix("**/") {
825            if wildmatch(suffix.as_bytes(), path_bytes, wm_flags) {
826                return true;
827            }
828        }
829    }
830
831    // Wildcard: require literal bytes up to `simple_length`, then wildmatch the tail only.
832    if simple < pattern.len() {
833        if path_bytes.len() < simple {
834            return false;
835        }
836        let path_lit = &path_bytes[..simple];
837        let pat_lit = &pattern_bytes[..simple];
838        let same = if magic.icase {
839            path_lit.eq_ignore_ascii_case(pat_lit)
840        } else {
841            path_lit == pat_lit
842        };
843        if !same {
844            return false;
845        }
846        let pat_rest = &pattern[simple..];
847        let path_rest = &path[simple..];
848        return wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), wm_flags);
849    }
850
851    ps_str_eq(pattern, path, magic.icase)
852        || path_starts_with(path, &format!("{pattern}/"), magic.icase)
853}
854
855fn ps_str_eq(a: &str, b: &str, icase: bool) -> bool {
856    if icase {
857        a.eq_ignore_ascii_case(b)
858    } else {
859        a == b
860    }
861}
862
863fn path_starts_with(path: &str, prefix: &str, icase: bool) -> bool {
864    if icase {
865        path.get(..prefix.len())
866            .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
867    } else {
868        path.starts_with(prefix)
869    }
870}
871
872fn literal_prefix_match(pattern: &str, path: &str) -> bool {
873    if let Some(prefix) = pattern.strip_suffix('/') {
874        return path == prefix || path.starts_with(&format!("{prefix}/"));
875    }
876    path == pattern || path.starts_with(&format!("{pattern}/"))
877}
878
879/// Literal pathspec match for `ls-tree` when the pattern has no `*`/`?` (brackets stay literal).
880fn ls_tree_literal_match(pattern: &str, path: &str, ctx: PathspecMatchContext) -> bool {
881    if let Some(prefix) = pattern.strip_suffix('/') {
882        if path.starts_with(&format!("{prefix}/")) {
883            return true;
884        }
885        if path == prefix {
886            return ctx.is_directory || ctx.is_git_submodule;
887        }
888        return false;
889    }
890    path == pattern || path.starts_with(&format!("{pattern}/"))
891}
892
893/// Optional path metadata for literal pathspecs with a trailing `/` (tree-walk / diff-tree).
894///
895/// Git treats `dir/` as “directory or git submodule only”: a regular file `dir`
896/// does not match, but a tree entry `dir` or gitlink `dir` does.
897#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
898pub struct PathspecMatchContext {
899    /// The index/tree entry is a directory (mode `040000`).
900    pub is_directory: bool,
901    /// The entry is a git submodule / gitlink (`160000`).
902    pub is_git_submodule: bool,
903}
904
905/// Returns whether `path` matches the pathspec `spec` with default (file) context.
906///
907/// For pathspecs ending in `/`, a path equal to the prefix matches only when
908/// [`PathspecMatchContext`] indicates a directory or submodule; see
909/// [`matches_pathspec_with_context`].
910#[must_use]
911pub fn matches_pathspec(spec: &str, path: &str) -> bool {
912    matches_pathspec_with_context(spec, path, PathspecMatchContext::default())
913}
914
915/// Like [`matches_pathspec`], but uses `ctx` for trailing-`/` literal pathspecs and for
916/// wildcard pathspecs where the pattern continues after a directory boundary (Git
917/// `matches_pathspec` + directory semantics).
918#[must_use]
919pub fn matches_pathspec_with_context(spec: &str, path: &str, ctx: PathspecMatchContext) -> bool {
920    let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
921        precompose_utf8_path(spec)
922    } else {
923        Cow::Borrowed(spec)
924    };
925    let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
926        precompose_utf8_path(path)
927    } else {
928        Cow::Borrowed(path)
929    };
930    let spec = spec_nfc.as_ref();
931    let path = path_nfc.as_ref();
932
933    let trimmed = spec.strip_prefix("./").unwrap_or(spec);
934    if trimmed == "." || trimmed.is_empty() {
935        return true;
936    }
937
938    let (elem_magic, raw_pattern) = parse_element_magic(trimmed);
939    let magic = combine_magic(elem_magic);
940
941    if magic.literal && magic.glob {
942        return false;
943    }
944    if magic.exclude {
945        return false;
946    }
947
948    let pattern = strip_top_magic(raw_pattern);
949    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
950        if !path.starts_with(prefix) {
951            return false;
952        }
953        &path[prefix.len()..]
954    } else {
955        path
956    };
957
958    if magic.literal {
959        if let Some(prefix) = pattern.strip_suffix('/') {
960            if path_for_match.starts_with(&format!("{prefix}/")) {
961                return true;
962            }
963            if path_for_match == prefix {
964                return ctx.is_directory || ctx.is_git_submodule;
965            }
966            return false;
967        }
968        return path_for_match == pattern || path_for_match.starts_with(&format!("{pattern}/"));
969    }
970
971    // No wildcards and trailing `/`: directory-only semantics (Git `matches_pathspec`).
972    if let Some(prefix) = pattern.strip_suffix('/') {
973        if simple_length(pattern) == pattern.len() {
974            if path_for_match.starts_with(&format!("{prefix}/")) {
975                return true;
976            }
977            if path_for_match == prefix {
978                return ctx.is_directory || ctx.is_git_submodule;
979            }
980            return false;
981        }
982    }
983
984    if pathspec_matches_tail(pattern, path_for_match, magic) {
985        return true;
986    }
987
988    if (ctx.is_directory || ctx.is_git_submodule)
989        && !path_for_match.is_empty()
990        && pattern.len() > path_for_match.len()
991        && pattern.as_bytes().get(path_for_match.len()) == Some(&b'/')
992        && pattern.starts_with(path_for_match)
993        && simple_length(pattern) < pattern.len()
994    {
995        return true;
996    }
997
998    false
999}
1000
1001/// Parse a Git mode string (e.g. `100644`, `040000`) into a [`PathspecMatchContext`].
1002#[must_use]
1003pub fn context_from_mode_octal(mode: &str) -> PathspecMatchContext {
1004    let Ok(bits) = u32::from_str_radix(mode, 8) else {
1005        return PathspecMatchContext::default();
1006    };
1007    context_from_mode_bits(bits)
1008}
1009
1010/// Classify a raw Git mode (e.g. from an index or tree entry) for pathspec matching.
1011#[must_use]
1012pub fn context_from_mode_bits(mode: u32) -> PathspecMatchContext {
1013    let ty = mode & 0o170000;
1014    PathspecMatchContext {
1015        is_directory: ty == 0o040000,
1016        is_git_submodule: ty == 0o160000,
1017    }
1018}
1019
1020/// Pathspec matching for `ls-tree` after Git forces `pathspec.has_wildcard = 0` (`ls-tree.c`).
1021///
1022/// Metacharacters `*` / `?` still participate in [`wildmatch`]; `[` and `\\` are **not** glob
1023/// starters unless a `*` or `?` appears — so `a[a]` matches the literal directory `a[a]` (t3102),
1024/// while `a*` matches `a/one`, `aa/two`, `a[a]/three`, …
1025#[must_use]
1026pub fn matches_ls_tree_pathspec(
1027    spec: &str,
1028    path: &str,
1029    mode: u32,
1030    attr_rules: &[AttrRule],
1031) -> bool {
1032    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1033    let mut magic = combine_magic(elem_magic);
1034    magic.exclude = false;
1035
1036    if magic.literal && magic.glob {
1037        return false;
1038    }
1039
1040    let ctx = context_from_mode_bits(mode);
1041    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1042
1043    if let Some(ref attr) = magic.attr_name {
1044        if !path_has_gitattribute(attr_rules, path, is_dir_for_attr, attr) {
1045            return false;
1046        }
1047    }
1048
1049    let pattern = strip_top_magic(raw_pattern);
1050    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1051        if !path.starts_with(prefix) {
1052            return false;
1053        }
1054        &path[prefix.len()..]
1055    } else {
1056        path
1057    };
1058
1059    if magic.literal || magic.glob || magic.icase {
1060        return pathspec_matches_tail(pattern, path_for_match, magic);
1061    }
1062
1063    let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1064        precompose_utf8_path(pattern)
1065    } else {
1066        Cow::Borrowed(pattern)
1067    };
1068    let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1069        precompose_utf8_path(path_for_match)
1070    } else {
1071        Cow::Borrowed(path_for_match)
1072    };
1073    let pattern = spec_nfc.as_ref();
1074    let path = path_nfc.as_ref();
1075
1076    let trimmed = pattern.strip_prefix("./").unwrap_or(pattern);
1077    if trimmed == "." || trimmed.is_empty() {
1078        return true;
1079    }
1080
1081    let uses_star_or_question = trimmed.contains('*') || trimmed.contains('?');
1082    if !uses_star_or_question {
1083        return ls_tree_literal_match(trimmed, path, ctx);
1084    }
1085
1086    let nwl = simple_length(trimmed);
1087    let flags = 0u32;
1088    if nwl == trimmed.len() {
1089        return wildmatch(trimmed.as_bytes(), path.as_bytes(), flags);
1090    }
1091    let lit = trimmed.as_bytes().get(..nwl).unwrap_or_default();
1092    let path_b = path.as_bytes();
1093    if path_b.len() < nwl {
1094        return false;
1095    }
1096    if &path_b[..nwl] != lit {
1097        return false;
1098    }
1099    let pat_rest = &trimmed[nwl..];
1100    let path_rest = &path[nwl..];
1101    wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), flags)
1102}
1103
1104/// Match a pathspec against a tree path, using `.gitattributes` for `:(attr:...)`.
1105///
1106/// Used by `git archive` style tree walks: `mode` supplies directory/gitlink context for
1107/// literal pathspecs ending in `/`.
1108#[must_use]
1109pub fn matches_pathspec_for_object(
1110    spec: &str,
1111    path: &str,
1112    mode: u32,
1113    attr_rules: &[AttrRule],
1114) -> bool {
1115    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1116    let mut magic = combine_magic(elem_magic);
1117    magic.exclude = false;
1118
1119    if magic.literal && magic.glob {
1120        return false;
1121    }
1122
1123    let ctx = context_from_mode_bits(mode);
1124    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1125
1126    if let Some(ref attr) = magic.attr_name {
1127        if !path_has_gitattribute(attr_rules, path, is_dir_for_attr, attr) {
1128            return false;
1129        }
1130    }
1131
1132    let pattern = strip_top_magic(raw_pattern);
1133    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1134        if !path.starts_with(prefix) {
1135            return false;
1136        }
1137        &path[prefix.len()..]
1138    } else {
1139        path
1140    };
1141    if magic.literal || magic.glob || magic.icase {
1142        pathspec_matches_tail(pattern, path_for_match, magic)
1143    } else {
1144        matches_pathspec_with_context(pattern, path_for_match, ctx)
1145    }
1146}
1147
1148/// Returns wildmatch flags for `:(icase)` / `:(glob)`-style patterns when those
1149/// appear as explicit magic (not used by default CLI pathspecs).
1150#[must_use]
1151pub fn wildmatch_flags_icase_glob(icase: bool, glob: bool) -> u32 {
1152    let mut f = if glob { WM_PATHNAME } else { 0 };
1153    if icase {
1154        f |= WM_CASEFOLD;
1155    }
1156    f
1157}
1158
1159#[cfg(test)]
1160mod tree_entry_pathspec_tests {
1161    use super::*;
1162
1163    #[test]
1164    fn t6130_bracket_filename_matches_pathspec() {
1165        assert!(matches_pathspec("f[o][o]", "f[o][o]"));
1166        assert!(matches_pathspec(":(glob)f[o][o]", "f[o][o]"));
1167    }
1168
1169    #[test]
1170    fn literal_prefix_and_exact() {
1171        assert!(matches_pathspec("path1", "path1/file1"));
1172        assert!(matches_pathspec_with_context(
1173            "path1/",
1174            "path1/file1",
1175            PathspecMatchContext::default()
1176        ));
1177        assert!(matches_pathspec("file0", "file0"));
1178        assert!(!matches_pathspec("path", "path1/file1"));
1179    }
1180
1181    #[test]
1182    fn ls_tree_bracket_in_name_is_literal_prefix() {
1183        assert!(matches_ls_tree_pathspec(
1184            "a[a]",
1185            "a[a]/three",
1186            0o100644,
1187            &[]
1188        ));
1189        assert!(!matches_pathspec_with_context(
1190            "a[a]",
1191            "a[a]/three",
1192            PathspecMatchContext::default()
1193        ));
1194    }
1195
1196    #[test]
1197    fn wildcards_cross_slash_by_default() {
1198        assert!(matches_pathspec("f*", "file0"));
1199        assert!(matches_pathspec("*file1", "path1/file1"));
1200        assert!(matches_pathspec_with_context(
1201            "path1/f*",
1202            "path1",
1203            PathspecMatchContext {
1204                is_directory: true,
1205                ..Default::default()
1206            }
1207        ));
1208        assert!(matches_pathspec("path1/*file1", "path1/file1"));
1209    }
1210
1211    #[test]
1212    fn glob_double_star_txt_at_repo_root() {
1213        assert!(pathspec_matches(":(glob)**/*.txt", "untracked.txt"));
1214        assert!(pathspec_matches(":(glob)**/*.txt", "d/untracked.txt"));
1215    }
1216
1217    #[test]
1218    fn trailing_slash_directory_only() {
1219        assert!(!matches_pathspec_with_context(
1220            "file0/",
1221            "file0",
1222            PathspecMatchContext::default()
1223        ));
1224        assert!(matches_pathspec_with_context(
1225            "file0/",
1226            "file0",
1227            PathspecMatchContext {
1228                is_directory: true,
1229                ..Default::default()
1230            }
1231        ));
1232        assert!(matches_pathspec_with_context(
1233            "submod/",
1234            "submod",
1235            PathspecMatchContext {
1236                is_git_submodule: true,
1237                ..Default::default()
1238            }
1239        ));
1240    }
1241
1242    #[test]
1243    fn exclude_top_short_magic_subtracts_from_positive() {
1244        let specs = vec!["*".to_string(), ":/!sub2".to_string()];
1245        assert!(matches_pathspec_list("sub/file", &specs));
1246        assert!(!matches_pathspec_list("sub2/file", &specs));
1247        assert!(pathspec_exclude_matches(":/!sub2", "sub2/file"));
1248    }
1249}
1250
1251#[cfg(test)]
1252mod pathspec_list_tests {
1253    use super::*;
1254
1255    #[test]
1256    fn exclude_removes_paths_matching_icase_positive() {
1257        let specs = vec![
1258            ":(icase)*.txt".to_string(),
1259            ":(exclude)submodule/subsub/*".to_string(),
1260        ];
1261        assert!(path_allowed_by_pathspec_list(&specs, "submodule/g.txt"));
1262        assert!(!path_allowed_by_pathspec_list(
1263            &specs,
1264            "submodule/subsub/e.txt"
1265        ));
1266    }
1267}