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_gitattribute_value;
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:...)` requirements.
147    attr_requirements: Vec<AttrRequirement>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151enum AttrRequirement {
152    Set(String),
153    Unset(String),
154    Unspecified(String),
155    Value(String, String),
156}
157
158impl AttrRequirement {
159    fn name(&self) -> &str {
160        match self {
161            AttrRequirement::Set(name)
162            | AttrRequirement::Unset(name)
163            | AttrRequirement::Unspecified(name)
164            | AttrRequirement::Value(name, _) => name,
165        }
166    }
167}
168
169fn parse_maybe_bool(v: &str) -> Option<bool> {
170    let s = v.trim().to_ascii_lowercase();
171    match s.as_str() {
172        "true" | "yes" | "on" | "1" => Some(true),
173        "false" | "no" | "off" | "0" => Some(false),
174        _ => None,
175    }
176}
177
178fn git_env_bool(key: &str, default: bool) -> bool {
179    match std::env::var(key) {
180        Ok(v) => parse_maybe_bool(&v).unwrap_or(default),
181        Err(_) => default,
182    }
183}
184
185fn literal_global() -> bool {
186    git_env_bool("GIT_LITERAL_PATHSPECS", false)
187}
188
189/// Whether `GIT_LITERAL_PATHSPECS` is enabled (shell `*` and `?` are literal, not globs).
190#[must_use]
191pub fn literal_pathspecs_enabled() -> bool {
192    literal_global()
193}
194
195fn glob_global() -> bool {
196    git_env_bool("GIT_GLOB_PATHSPECS", false)
197}
198
199fn noglob_global() -> bool {
200    git_env_bool("GIT_NOGLOB_PATHSPECS", false)
201}
202
203fn icase_global() -> bool {
204    git_env_bool("GIT_ICASE_PATHSPECS", false)
205}
206
207/// Validates global pathspec environment flags the same way Git does.
208///
209/// Returns an error message suitable for `bail!` when flags are incompatible.
210pub fn validate_global_pathspec_flags() -> Result<(), String> {
211    let lit = literal_global();
212    let glob = glob_global();
213    let noglob = noglob_global();
214    let icase = icase_global();
215
216    if glob && noglob {
217        return Err("global 'glob' and 'noglob' pathspec settings are incompatible".to_string());
218    }
219    if lit && (glob || noglob || icase) {
220        return Err(
221            "global 'literal' pathspec setting is incompatible with all other global pathspec settings"
222                .to_string(),
223        );
224    }
225    Ok(())
226}
227
228fn is_valid_attr_name(name: &str) -> bool {
229    !name.is_empty()
230        && name
231            .bytes()
232            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
233}
234
235fn split_attr_expr(expr: &str) -> Result<Vec<String>, String> {
236    let mut parts = Vec::new();
237    let mut cur = String::new();
238    let mut in_value = false;
239    let mut escaped = false;
240
241    for ch in expr.chars() {
242        if escaped {
243            if ch.is_ascii_whitespace() {
244                return Err(
245                    "Escape character '\\' not allowed as last character in attr value".to_string(),
246                );
247            }
248            if ch != ',' {
249                return Err("Escape character '\\' not allowed for value matching".to_string());
250            }
251            cur.push(ch);
252            escaped = false;
253            continue;
254        }
255        if in_value && ch == '\\' {
256            escaped = true;
257            continue;
258        }
259        if ch == '=' {
260            in_value = true;
261            cur.push(ch);
262            continue;
263        }
264        if ch.is_ascii_whitespace() {
265            if !cur.is_empty() {
266                parts.push(cur);
267                cur = String::new();
268            }
269            in_value = false;
270            continue;
271        }
272        cur.push(ch);
273    }
274
275    if escaped {
276        return Err(
277            "Escape character '\\' not allowed as last character in attr value".to_string(),
278        );
279    }
280    if !cur.is_empty() {
281        parts.push(cur);
282    }
283    Ok(parts)
284}
285
286fn parse_attr_requirements(expr: &str) -> Result<Vec<AttrRequirement>, String> {
287    if expr.trim().is_empty() {
288        return Err("empty attr magic is invalid".to_string());
289    }
290    let mut out = Vec::new();
291    for token in split_attr_expr(expr)? {
292        if let Some(name) = token.strip_prefix('-') {
293            if name.contains('=') {
294                return Err("invalid attribute name".to_string());
295            }
296            if !is_valid_attr_name(name) {
297                return Err(format!("{name} is not a valid attribute name"));
298            }
299            out.push(AttrRequirement::Unset(name.to_string()));
300        } else if let Some(name) = token.strip_prefix('!') {
301            if name.contains('=') {
302                return Err("invalid attribute name".to_string());
303            }
304            if !is_valid_attr_name(name) {
305                return Err(format!("{name} is not a valid attribute name"));
306            }
307            out.push(AttrRequirement::Unspecified(name.to_string()));
308        } else if let Some((name, value)) = token.split_once('=') {
309            if !is_valid_attr_name(name) {
310                return Err(format!("{name} is not a valid attribute name"));
311            }
312            if value.is_empty() {
313                return Err("empty attribute value is not allowed".to_string());
314            }
315            out.push(AttrRequirement::Value(name.to_string(), value.to_string()));
316        } else {
317            if !is_valid_attr_name(&token) {
318                return Err(format!("{token} is not a valid attribute name"));
319            }
320            out.push(AttrRequirement::Set(token));
321        }
322    }
323    if out.is_empty() {
324        return Err("empty attr magic is invalid".to_string());
325    }
326    Ok(out)
327}
328
329/// Validate `:(attr:...)` pathspec magic in `specs`.
330///
331/// Returns `Ok(())` when all attribute magic is parseable. Returns a Git-style error string for
332/// unsupported or malformed attribute magic.
333pub fn validate_attr_pathspecs(specs: &[String]) -> Result<(), String> {
334    for spec in specs {
335        if literal_global() || !spec.starts_with(":(") {
336            continue;
337        }
338        let Some(rest) = spec.strip_prefix(":(") else {
339            continue;
340        };
341        let Some(close) = rest.find(')') else {
342            continue;
343        };
344        let magic_part = &rest[..close];
345        let mut attr_count = 0usize;
346        for token in split_long_magic_tokens(magic_part) {
347            let Some(expr) = token.trim().strip_prefix("attr:") else {
348                continue;
349            };
350            attr_count += 1;
351            if attr_count > 1 {
352                return Err("Only one 'attr:' specification is allowed.".to_string());
353            }
354            parse_attr_requirements(expr)?;
355        }
356    }
357    Ok(())
358}
359
360fn split_long_magic_tokens(magic_part: &str) -> Vec<String> {
361    let mut tokens = Vec::new();
362    let mut cur = String::new();
363    let mut escaped = false;
364    for ch in magic_part.chars() {
365        if escaped {
366            cur.push('\\');
367            cur.push(ch);
368            escaped = false;
369            continue;
370        }
371        if ch == '\\' {
372            escaped = true;
373            continue;
374        }
375        if ch == ',' {
376            tokens.push(cur.trim().to_string());
377            cur.clear();
378            continue;
379        }
380        cur.push(ch);
381    }
382    if escaped {
383        cur.push('\\');
384    }
385    tokens.push(cur.trim().to_string());
386    tokens
387}
388
389fn parse_long_magic(rest_after_paren: &str) -> Option<(PathspecMagic, &str)> {
390    let close = rest_after_paren.find(')')?;
391    let magic_part = &rest_after_paren[..close];
392    let tail = &rest_after_paren[close + 1..];
393    let mut magic = PathspecMagic::default();
394    for raw in split_long_magic_tokens(magic_part) {
395        let token = raw.trim();
396        if token.is_empty() {
397            continue;
398        }
399        if let Some(p) = token.strip_prefix("prefix:") {
400            magic.prefix = Some(p.to_string());
401            continue;
402        }
403        if let Some(expr) = token.strip_prefix("attr:") {
404            if let Ok(reqs) = parse_attr_requirements(expr) {
405                magic.attr_requirements = reqs;
406            }
407            continue;
408        }
409        if token.eq_ignore_ascii_case("literal") {
410            magic.literal = true;
411        } else if token.eq_ignore_ascii_case("glob") {
412            magic.glob = true;
413        } else if token.eq_ignore_ascii_case("icase") {
414            magic.icase = true;
415        } else if token.eq_ignore_ascii_case("exclude") {
416            magic.exclude = true;
417        } else if token.eq_ignore_ascii_case("top") {
418            magic.top = true;
419        }
420    }
421    Some((magic, tail))
422}
423
424/// `elem` is the full pathspec beginning with `:` (short magic form, not `:(...)`).
425fn parse_short_magic(elem: &str) -> (PathspecMagic, &str) {
426    let bytes = elem.as_bytes();
427    let mut i = 1usize;
428    let mut magic = PathspecMagic::default();
429    while i < bytes.len() && bytes[i] != b':' {
430        let ch = bytes[i];
431        if ch == b'^' {
432            magic.exclude = true;
433            i += 1;
434            continue;
435        }
436        let is_magic = match ch {
437            b'!' => {
438                magic.exclude = true;
439                true
440            }
441            b'/' => {
442                magic.top = true;
443                true
444            } // short `:/` = top
445            _ => false,
446        };
447        if is_magic {
448            i += 1;
449            continue;
450        }
451        break;
452    }
453    if i < bytes.len() && bytes[i] == b':' {
454        i += 1;
455    }
456    (magic, &elem[i..])
457}
458
459/// Strip `:(magic)` / `:magic` prefix when not in literal-global mode.
460fn parse_element_magic(elem: &str) -> (PathspecMagic, &str) {
461    if !elem.starts_with(':') || literal_global() {
462        return (PathspecMagic::default(), elem);
463    }
464    if let Some(rest) = elem.strip_prefix(":(") {
465        return parse_long_magic(rest).unwrap_or((PathspecMagic::default(), elem));
466    }
467    parse_short_magic(elem)
468}
469
470fn combine_magic(element: PathspecMagic) -> PathspecMagic {
471    let mut m = element;
472    if literal_global() {
473        m.literal = true;
474    }
475    if glob_global() && !m.literal {
476        m.glob = true;
477    }
478    if icase_global() {
479        m.icase = true;
480    }
481    if noglob_global() && !m.glob {
482        m.literal = true;
483    }
484    m
485}
486
487fn strip_top_magic(mut pattern: &str) -> &str {
488    if let Some(r) = pattern.strip_prefix(":/") {
489        pattern = r;
490    }
491    pattern
492}
493
494/// Path prefix used for Bloom-filter lookups (`revision.c` `convert_pathspec_to_bloom_keyvec`).
495///
496/// `cwd_from_repo_root` is the path from the repository work tree to the process cwd, using `/`
497/// separators and no leading slash (empty string at repo root). Used for `:(top)` / `:/`.
498#[must_use]
499pub fn bloom_lookup_prefix_with_cwd(
500    spec: &str,
501    cwd_from_repo_root: Option<&str>,
502) -> Option<String> {
503    let (elem_magic, raw_pattern) = parse_element_magic(spec);
504    let magic = combine_magic(elem_magic);
505    if magic.exclude || magic.icase {
506        return None;
507    }
508    let pattern = strip_top_magic(raw_pattern);
509    if pattern.is_empty() {
510        return None;
511    }
512    let combined = if magic.top {
513        let cwd = cwd_from_repo_root.unwrap_or("").trim_end_matches('/');
514        if cwd.is_empty() {
515            pattern.to_string()
516        } else {
517            format!("{cwd}/{pattern}")
518        }
519    } else {
520        pattern.to_string()
521    };
522    let pattern = combined.as_str();
523    let mut len = simple_length(pattern);
524    if len != pattern.len() {
525        while len > 0 && pattern.as_bytes()[len - 1] != b'/' {
526            len -= 1;
527        }
528    }
529    while len > 0 && pattern.as_bytes()[len - 1] == b'/' {
530        len -= 1;
531    }
532    if len == 0 {
533        return None;
534    }
535    Some(combined[..len].to_string())
536}
537
538#[must_use]
539pub fn bloom_lookup_prefix(spec: &str) -> Option<String> {
540    bloom_lookup_prefix_with_cwd(spec, None)
541}
542
543/// Whether every pathspec can participate in Bloom precomputation (Git `forbid_bloom_filters`).
544#[must_use]
545pub fn pathspecs_allow_bloom(specs: &[String]) -> bool {
546    specs.iter().all(|s| {
547        !s.is_empty() && !pathspec_is_exclude(s) && bloom_lookup_prefix_with_cwd(s, None).is_some()
548    })
549}
550
551/// Whether `path` is included when Git applies a pathspec list with optional `:(exclude)` entries.
552///
553/// A path is rejected if any exclude pathspec matches it. When at least one non-exclude pathspec is
554/// present, the path must also match one of those positives (`OR` semantics).
555#[must_use]
556pub fn path_allowed_by_pathspec_list(specs: &[String], path: &str) -> bool {
557    let mut has_positive = false;
558    let mut positive_match = false;
559    for s in specs {
560        let (elem, raw_pattern) = parse_element_magic(s);
561        let magic = combine_magic(elem);
562        if magic.exclude {
563            if path_matches_pathspec_tail(raw_pattern, path, magic) {
564                return false;
565            }
566            continue;
567        }
568        has_positive = true;
569        if pathspec_matches(s, path) {
570            positive_match = true;
571        }
572    }
573    !has_positive || positive_match
574}
575
576/// True when `spec` matches `path` for pathspec bookkeeping (positive match or exclude hit).
577#[must_use]
578pub fn pathspec_contributes_match(spec: &str, path: &str) -> bool {
579    pathspec_matches(spec, path) || pathspec_exclude_matches(spec, path)
580}
581
582fn path_matches_pathspec_tail(raw_pattern: &str, path: &str, magic: PathspecMagic) -> bool {
583    if magic.literal && magic.glob {
584        return false;
585    }
586    let pattern = strip_top_magic(raw_pattern);
587    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
588        if !path.starts_with(prefix) {
589            return false;
590        }
591        &path[prefix.len()..]
592    } else {
593        path
594    };
595    pathspec_matches_tail(pattern, path_for_match, magic)
596}
597
598/// True if `path` is matched by `spec` (Git pathspec syntax, including magic and globals).
599///
600/// Same as [`matches_pathspec`] (default file context; exclude specs never match positively here).
601/// See [`matches_pathspec_list`].
602#[must_use]
603pub fn pathspec_matches(spec: &str, path: &str) -> bool {
604    matches_pathspec(spec, path)
605}
606
607/// Returns whether `spec` uses Git's exclude magic (`:(exclude)`, `:!`, `:^`, etc.).
608#[must_use]
609pub fn pathspec_is_exclude(spec: &str) -> bool {
610    let (elem_magic, _) = parse_element_magic(spec);
611    combine_magic(elem_magic).exclude
612}
613
614/// Whether tree-walking should recurse into directory `full_name` for pathspec `spec` without
615/// `-r` (Git `read_tree` / `show_recursive` “interesting” descent).
616///
617/// Exclude-only patterns never trigger descent alone.
618#[must_use]
619pub fn pathspec_wants_descent_into_tree(spec: &str, full_name: &str) -> bool {
620    if pathspec_is_exclude(spec) {
621        return false;
622    }
623    let (elem_magic, raw_pattern) = parse_element_magic(spec);
624    let magic = combine_magic(elem_magic);
625    if magic.exclude {
626        return false;
627    }
628    let pattern = strip_top_magic(raw_pattern);
629    let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
630    if pattern.is_empty() || pattern == "." {
631        return true;
632    }
633    let dir_prefix = format!("{full_name}/");
634    if pattern.starts_with(&dir_prefix) {
635        return true;
636    }
637    let probe = format!("{full_name}/.__grit_ls_tree_probe__");
638    matches_ls_tree_pathspec(spec, &probe, 0o100644, &[])
639}
640
641/// Like [`matches_pathspec_set_for_object`], but uses [`matches_ls_tree_pathspec`] for each
642/// element so `ls-files` / index filtering agrees with `ls-tree` on patterns such as `a[a]`.
643#[must_use]
644pub fn matches_pathspec_set_for_object_ls_tree(
645    specs: &[String],
646    path: &str,
647    mode: u32,
648    attr_rules: &[AttrRule],
649) -> bool {
650    if specs.is_empty() {
651        return true;
652    }
653    let mut positives: Vec<&str> = Vec::new();
654    let mut excludes: Vec<&str> = Vec::new();
655    for s in specs {
656        if pathspec_is_exclude(s) {
657            excludes.push(s.as_str());
658        } else {
659            positives.push(s.as_str());
660        }
661    }
662    let positive_ok = if positives.is_empty() {
663        true
664    } else {
665        positives
666            .iter()
667            .any(|s| matches_ls_tree_pathspec(s, path, mode, attr_rules))
668    };
669    if !positive_ok {
670        return false;
671    }
672    for ex in excludes {
673        if matches_ls_tree_pathspec(ex, path, mode, attr_rules) {
674            return false;
675        }
676    }
677    true
678}
679
680/// True if `path` matches the combined pathspec list: any positive spec (or all paths when there
681/// are only excludes, matching Git `parse_pathspec`), and not matched by any exclude spec.
682#[must_use]
683pub fn matches_pathspec_set_for_object(
684    specs: &[String],
685    path: &str,
686    mode: u32,
687    attr_rules: &[AttrRule],
688) -> bool {
689    if specs.is_empty() {
690        return true;
691    }
692    let mut positives: Vec<&str> = Vec::new();
693    let mut excludes: Vec<&str> = Vec::new();
694    for s in specs {
695        if pathspec_is_exclude(s) {
696            excludes.push(s.as_str());
697        } else {
698            positives.push(s.as_str());
699        }
700    }
701    let positive_ok = if positives.is_empty() {
702        true
703    } else {
704        positives
705            .iter()
706            .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
707    };
708    if !positive_ok {
709        return false;
710    }
711    for ex in excludes {
712        if matches_pathspec_for_object(ex, path, mode, attr_rules) {
713            return false;
714        }
715    }
716    true
717}
718
719/// True if `spec` uses `:(top)` or short `:/` (repo-root-relative) magic.
720#[must_use]
721pub fn pathspec_has_top(spec: &str) -> bool {
722    let (elem_magic, _) = parse_element_magic(spec);
723    combine_magic(elem_magic).top
724}
725
726fn pathspec_match_one_positive(path: &str, magic: PathspecMagic, raw_pattern: &str) -> bool {
727    if magic.literal && magic.glob {
728        return false;
729    }
730    let pattern = strip_top_magic(raw_pattern);
731    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
732        if !path.starts_with(prefix) {
733            return false;
734        }
735        &path[prefix.len()..]
736    } else {
737        path
738    };
739    pathspec_matches_tail(pattern, path_for_match, magic)
740}
741
742fn attr_requirements_match(
743    requirements: &[AttrRequirement],
744    attr_rules: &[AttrRule],
745    path: &str,
746    is_dir: bool,
747    mode: u32,
748) -> bool {
749    requirements.iter().all(|req| {
750        let value = if req.name() == "builtin_objectmode" {
751            if mode == 0 {
752                None
753            } else {
754                Some(format!("{mode:06o}"))
755            }
756        } else {
757            path_gitattribute_value(attr_rules, path, is_dir, req.name())
758        };
759        match req {
760            AttrRequirement::Set(_) => value.as_deref() == Some("set"),
761            AttrRequirement::Unset(_) => value.as_deref() == Some("unset"),
762            AttrRequirement::Unspecified(_) => value.is_none(),
763            AttrRequirement::Value(_, expected) => value.as_deref() == Some(expected.as_str()),
764        }
765    })
766}
767
768fn matches_pathspec_element_with_context(
769    spec: &str,
770    path: &str,
771    ctx: PathspecMatchContext,
772) -> bool {
773    let (elem_magic, raw_pattern) = parse_element_magic(spec);
774    let magic = combine_magic(elem_magic);
775    if magic.exclude {
776        return false;
777    }
778    if magic.literal && magic.glob {
779        return false;
780    }
781    if !magic.attr_requirements.is_empty() {
782        return false;
783    }
784    if magic.literal || magic.glob || magic.icase {
785        return pathspec_matches(spec, path);
786    }
787    let pattern = strip_top_magic(raw_pattern);
788    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
789        if !path.starts_with(prefix) {
790            return false;
791        }
792        &path[prefix.len()..]
793    } else {
794        path
795    };
796    matches_pathspec_with_context(pattern, path_for_match, ctx)
797}
798
799fn pathspec_exclude_element_matches_with_context(
800    spec: &str,
801    path: &str,
802    ctx: PathspecMatchContext,
803) -> bool {
804    let (elem_magic, raw_pattern) = parse_element_magic(spec);
805    let mut magic = combine_magic(elem_magic);
806    if !magic.exclude {
807        return false;
808    }
809    magic.exclude = false;
810    if magic.literal && magic.glob {
811        return false;
812    }
813    if !magic.attr_requirements.is_empty() {
814        // Attribute pathspecs need `.gitattributes` context; use
815        // [`matches_pathspec_list_for_object`] for those.
816        return false;
817    }
818    if magic.literal || magic.glob || magic.icase {
819        return pathspec_match_one_positive(path, magic, raw_pattern);
820    }
821    let pattern = strip_top_magic(raw_pattern);
822    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
823        if !path.starts_with(prefix) {
824            return false;
825        }
826        &path[prefix.len()..]
827    } else {
828        path
829    };
830    matches_pathspec_with_context(pattern, path_for_match, ctx)
831}
832
833/// True if `path` is matched by an exclude pathspec's pattern. Returns `false` if `spec` is not
834/// an exclude pathspec.
835#[must_use]
836pub fn pathspec_exclude_matches(spec: &str, path: &str) -> bool {
837    pathspec_exclude_element_matches_with_context(spec, path, PathspecMatchContext::default())
838}
839
840/// When every pathspec is an exclude and none use `:(top)` / `:/`, Git prepends an implicit
841/// positive that matches only under the process cwd (relative to the work tree), not the whole
842/// repository (`PATHSPEC_PREFER_CWD` in `pathspec.c`). `cwd_from_repo_root` is that prefix
843/// without a trailing slash, or empty at the work tree root.
844#[must_use]
845pub fn extend_pathspec_list_implicit_cwd(
846    specs: &[String],
847    cwd_from_repo_root: Option<&str>,
848) -> Vec<String> {
849    if specs.is_empty() {
850        return specs.to_vec();
851    }
852    if !specs.iter().all(|s| pathspec_is_exclude(s)) {
853        return specs.to_vec();
854    }
855    let any_top = specs.iter().any(|s| pathspec_has_top(s));
856    if any_top {
857        return specs.to_vec();
858    }
859    let Some(cwd) = cwd_from_repo_root.map(str::trim).filter(|s| !s.is_empty()) else {
860        return specs.to_vec();
861    };
862    let cwd = cwd.trim_end_matches('/');
863    if cwd.is_empty() {
864        return specs.to_vec();
865    }
866    let mut out = Vec::with_capacity(specs.len() + 1);
867    out.push(format!("{cwd}/"));
868    out.extend_from_slice(specs);
869    out
870}
871
872/// Git `match_pathspec` semantics over a pathspec list: OR of positive specs minus OR of exclude
873/// specs. If every element is exclude-only, Git implicitly prepends `.` (match all); this
874/// function does the same.
875#[must_use]
876pub fn matches_pathspec_list(path: &str, specs: &[String]) -> bool {
877    matches_pathspec_list_with_context(path, specs, PathspecMatchContext::default())
878}
879
880/// Like [`matches_pathspec_list`], but uses `ctx` for non-magic pathspec elements (trailing `/`).
881#[must_use]
882pub fn matches_pathspec_list_with_context(
883    path: &str,
884    specs: &[String],
885    ctx: PathspecMatchContext,
886) -> bool {
887    if specs.is_empty() {
888        return true;
889    }
890    let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
891    let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
892    let positive = if positive_specs.is_empty() {
893        true
894    } else {
895        positive_specs
896            .iter()
897            .any(|s| matches_pathspec_element_with_context(s, path, ctx))
898    };
899    if !positive {
900        return false;
901    }
902    if !has_exclude {
903        return true;
904    }
905    let excluded = specs.iter().any(|s| {
906        pathspec_is_exclude(s) && pathspec_exclude_element_matches_with_context(s, path, ctx)
907    });
908    !excluded
909}
910
911/// `matches_pathspec_list` for tree/index objects with mode and `.gitattributes` rules.
912#[must_use]
913pub fn matches_pathspec_list_for_object(
914    path: &str,
915    mode: u32,
916    attr_rules: &[AttrRule],
917    specs: &[String],
918) -> bool {
919    if specs.is_empty() {
920        return true;
921    }
922    let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
923    let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
924    let positive = if positive_specs.is_empty() {
925        true
926    } else {
927        positive_specs
928            .iter()
929            .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
930    };
931    if !positive {
932        return false;
933    }
934    if !has_exclude {
935        return true;
936    }
937    let excluded = specs.iter().any(|s| {
938        pathspec_is_exclude(s) && matches_pathspec_exclude_for_object(s, path, mode, attr_rules)
939    });
940    !excluded
941}
942
943fn matches_pathspec_exclude_for_object(
944    spec: &str,
945    path: &str,
946    mode: u32,
947    attr_rules: &[AttrRule],
948) -> bool {
949    let (elem_magic, raw_pattern) = parse_element_magic(spec);
950    let mut magic = combine_magic(elem_magic);
951    if !magic.exclude {
952        return false;
953    }
954    magic.exclude = false;
955    if magic.literal && magic.glob {
956        return false;
957    }
958    let ctx = context_from_mode_bits(mode);
959    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
960    if !magic.attr_requirements.is_empty()
961        && !attr_requirements_match(
962            &magic.attr_requirements,
963            attr_rules,
964            path,
965            is_dir_for_attr,
966            mode,
967        )
968    {
969        return false;
970    }
971    let pattern = strip_top_magic(raw_pattern);
972    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
973        if !path.starts_with(prefix) {
974            return false;
975        }
976        &path[prefix.len()..]
977    } else {
978        path
979    };
980    if magic.literal || magic.glob || magic.icase {
981        pathspec_matches_tail(pattern, path_for_match, magic)
982    } else {
983        matches_pathspec_with_context(pattern, path_for_match, ctx)
984    }
985}
986
987fn pathspec_matches_tail(pattern: &str, path: &str, magic: PathspecMagic) -> bool {
988    if pattern.is_empty() {
989        return true;
990    }
991
992    let flags = if magic.icase { WM_CASEFOLD } else { 0 };
993
994    if magic.literal {
995        return literal_prefix_match(pattern, path);
996    }
997
998    let wm_flags = if magic.glob {
999        flags | WM_PATHNAME
1000    } else {
1001        flags
1002    };
1003
1004    let pattern_bytes = pattern.as_bytes();
1005    let path_bytes = path.as_bytes();
1006    let simple = simple_length(pattern);
1007
1008    // Git `match_pathspec_item`: exact / directory prefix before `git_fnmatch`.
1009    // Only when the pattern has no glob metacharacters (`simple_length` spans the whole pattern);
1010    // otherwise a pattern like `a[a]` must not match children via `a[a]/` prefix (t6130 vs ls-tree).
1011    if ps_str_eq(pattern, path, magic.icase) {
1012        return true;
1013    }
1014    if simple == pattern.len() {
1015        if let Some(prefix) = pattern.strip_suffix('/') {
1016            if ps_str_eq(prefix, path, magic.icase) {
1017                return true;
1018            }
1019            let prefix_slash = format!("{prefix}/");
1020            if path_starts_with(path, &prefix_slash, magic.icase) {
1021                return true;
1022            }
1023        } else {
1024            let prefix_slash = format!("{pattern}/");
1025            if path_starts_with(path, &prefix_slash, magic.icase) {
1026                return true;
1027            }
1028        }
1029    }
1030
1031    // `:(glob)**/*.txt` at repo root: Git matches `untracked.txt` (leading `**/` is optional).
1032    if magic.glob && !path.contains('/') && pattern.starts_with("**/") {
1033        if wildmatch(pattern_bytes, path_bytes, wm_flags) {
1034            return true;
1035        }
1036        if let Some(suffix) = pattern.strip_prefix("**/") {
1037            if wildmatch(suffix.as_bytes(), path_bytes, wm_flags) {
1038                return true;
1039            }
1040        }
1041    }
1042
1043    // Wildcard: require literal bytes up to `simple_length`, then wildmatch the tail only.
1044    if simple < pattern.len() {
1045        if path_bytes.len() < simple {
1046            return false;
1047        }
1048        let path_lit = &path_bytes[..simple];
1049        let pat_lit = &pattern_bytes[..simple];
1050        let same = if magic.icase {
1051            path_lit.eq_ignore_ascii_case(pat_lit)
1052        } else {
1053            path_lit == pat_lit
1054        };
1055        if !same {
1056            return false;
1057        }
1058        let pat_rest = &pattern[simple..];
1059        let path_rest = &path[simple..];
1060        return wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), wm_flags);
1061    }
1062
1063    ps_str_eq(pattern, path, magic.icase)
1064        || path_starts_with(path, &format!("{pattern}/"), magic.icase)
1065}
1066
1067fn ps_str_eq(a: &str, b: &str, icase: bool) -> bool {
1068    if icase {
1069        a.eq_ignore_ascii_case(b)
1070    } else {
1071        a == b
1072    }
1073}
1074
1075fn path_starts_with(path: &str, prefix: &str, icase: bool) -> bool {
1076    if icase {
1077        path.get(..prefix.len())
1078            .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
1079    } else {
1080        path.starts_with(prefix)
1081    }
1082}
1083
1084fn literal_prefix_match(pattern: &str, path: &str) -> bool {
1085    if let Some(prefix) = pattern.strip_suffix('/') {
1086        return path == prefix || path.starts_with(&format!("{prefix}/"));
1087    }
1088    path == pattern || path.starts_with(&format!("{pattern}/"))
1089}
1090
1091/// Literal pathspec match for `ls-tree` when the pattern has no `*`/`?` (brackets stay literal).
1092fn ls_tree_literal_match(pattern: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1093    if let Some(prefix) = pattern.strip_suffix('/') {
1094        if path.starts_with(&format!("{prefix}/")) {
1095            return true;
1096        }
1097        if path == prefix {
1098            return ctx.is_directory || ctx.is_git_submodule;
1099        }
1100        return false;
1101    }
1102    path == pattern || path.starts_with(&format!("{pattern}/"))
1103}
1104
1105/// Optional path metadata for literal pathspecs with a trailing `/` (tree-walk / diff-tree).
1106///
1107/// Git treats `dir/` as “directory or git submodule only”: a regular file `dir`
1108/// does not match, but a tree entry `dir` or gitlink `dir` does.
1109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1110pub struct PathspecMatchContext {
1111    /// The index/tree entry is a directory (mode `040000`).
1112    pub is_directory: bool,
1113    /// The entry is a git submodule / gitlink (`160000`).
1114    pub is_git_submodule: bool,
1115}
1116
1117/// Returns whether `path` matches the pathspec `spec` with default (file) context.
1118///
1119/// For pathspecs ending in `/`, a path equal to the prefix matches only when
1120/// [`PathspecMatchContext`] indicates a directory or submodule; see
1121/// [`matches_pathspec_with_context`].
1122#[must_use]
1123pub fn matches_pathspec(spec: &str, path: &str) -> bool {
1124    matches_pathspec_with_context(spec, path, PathspecMatchContext::default())
1125}
1126
1127/// Like [`matches_pathspec`], but uses `ctx` for trailing-`/` literal pathspecs and for
1128/// wildcard pathspecs where the pattern continues after a directory boundary (Git
1129/// `matches_pathspec` + directory semantics).
1130#[must_use]
1131pub fn matches_pathspec_with_context(spec: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1132    let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1133        precompose_utf8_path(spec)
1134    } else {
1135        Cow::Borrowed(spec)
1136    };
1137    let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1138        precompose_utf8_path(path)
1139    } else {
1140        Cow::Borrowed(path)
1141    };
1142    let spec = spec_nfc.as_ref();
1143    let path = path_nfc.as_ref();
1144
1145    let trimmed = spec.strip_prefix("./").unwrap_or(spec);
1146    if trimmed == "." || trimmed.is_empty() {
1147        return true;
1148    }
1149
1150    let (elem_magic, raw_pattern) = parse_element_magic(trimmed);
1151    let magic = combine_magic(elem_magic);
1152
1153    if magic.literal && magic.glob {
1154        return false;
1155    }
1156    if magic.exclude {
1157        return false;
1158    }
1159
1160    let pattern = strip_top_magic(raw_pattern);
1161    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1162        if !path.starts_with(prefix) {
1163            return false;
1164        }
1165        &path[prefix.len()..]
1166    } else {
1167        path
1168    };
1169
1170    if magic.literal {
1171        if let Some(prefix) = pattern.strip_suffix('/') {
1172            if path_for_match.starts_with(&format!("{prefix}/")) {
1173                return true;
1174            }
1175            if path_for_match == prefix {
1176                return ctx.is_directory || ctx.is_git_submodule;
1177            }
1178            return false;
1179        }
1180        return path_for_match == pattern || path_for_match.starts_with(&format!("{pattern}/"));
1181    }
1182
1183    // No wildcards and trailing `/`: directory-only semantics (Git `matches_pathspec`).
1184    if let Some(prefix) = pattern.strip_suffix('/') {
1185        if simple_length(pattern) == pattern.len() {
1186            if path_for_match.starts_with(&format!("{prefix}/")) {
1187                return true;
1188            }
1189            if path_for_match == prefix {
1190                return ctx.is_directory || ctx.is_git_submodule;
1191            }
1192            return false;
1193        }
1194    }
1195
1196    if pathspec_matches_tail(pattern, path_for_match, magic) {
1197        return true;
1198    }
1199
1200    if (ctx.is_directory || ctx.is_git_submodule)
1201        && !path_for_match.is_empty()
1202        && pattern.len() > path_for_match.len()
1203        && pattern.as_bytes().get(path_for_match.len()) == Some(&b'/')
1204        && pattern.starts_with(path_for_match)
1205        && simple_length(pattern) < pattern.len()
1206    {
1207        return true;
1208    }
1209
1210    false
1211}
1212
1213/// Parse a Git mode string (e.g. `100644`, `040000`) into a [`PathspecMatchContext`].
1214#[must_use]
1215pub fn context_from_mode_octal(mode: &str) -> PathspecMatchContext {
1216    let Ok(bits) = u32::from_str_radix(mode, 8) else {
1217        return PathspecMatchContext::default();
1218    };
1219    context_from_mode_bits(bits)
1220}
1221
1222/// Classify a raw Git mode (e.g. from an index or tree entry) for pathspec matching.
1223#[must_use]
1224pub fn context_from_mode_bits(mode: u32) -> PathspecMatchContext {
1225    let ty = mode & 0o170000;
1226    PathspecMatchContext {
1227        is_directory: ty == 0o040000,
1228        is_git_submodule: ty == 0o160000,
1229    }
1230}
1231
1232/// Pathspec matching for `ls-tree` after Git forces `pathspec.has_wildcard = 0` (`ls-tree.c`).
1233///
1234/// Metacharacters `*` / `?` still participate in [`wildmatch`]; `[` and `\\` are **not** glob
1235/// starters unless a `*` or `?` appears — so `a[a]` matches the literal directory `a[a]` (t3102),
1236/// while `a*` matches `a/one`, `aa/two`, `a[a]/three`, …
1237#[must_use]
1238pub fn matches_ls_tree_pathspec(
1239    spec: &str,
1240    path: &str,
1241    mode: u32,
1242    attr_rules: &[AttrRule],
1243) -> bool {
1244    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1245    let mut magic = combine_magic(elem_magic);
1246    magic.exclude = false;
1247
1248    if magic.literal && magic.glob {
1249        return false;
1250    }
1251
1252    let ctx = context_from_mode_bits(mode);
1253    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1254
1255    if !magic.attr_requirements.is_empty()
1256        && !attr_requirements_match(
1257            &magic.attr_requirements,
1258            attr_rules,
1259            path,
1260            is_dir_for_attr,
1261            mode,
1262        )
1263    {
1264        return false;
1265    }
1266
1267    let pattern = strip_top_magic(raw_pattern);
1268    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1269        if !path.starts_with(prefix) {
1270            return false;
1271        }
1272        &path[prefix.len()..]
1273    } else {
1274        path
1275    };
1276
1277    if magic.literal || magic.glob || magic.icase {
1278        return pathspec_matches_tail(pattern, path_for_match, magic);
1279    }
1280
1281    let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1282        precompose_utf8_path(pattern)
1283    } else {
1284        Cow::Borrowed(pattern)
1285    };
1286    let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1287        precompose_utf8_path(path_for_match)
1288    } else {
1289        Cow::Borrowed(path_for_match)
1290    };
1291    let pattern = spec_nfc.as_ref();
1292    let path = path_nfc.as_ref();
1293
1294    let trimmed = pattern.strip_prefix("./").unwrap_or(pattern);
1295    if trimmed == "." || trimmed.is_empty() {
1296        return true;
1297    }
1298
1299    let uses_star_or_question = trimmed.contains('*') || trimmed.contains('?');
1300    if !uses_star_or_question {
1301        return ls_tree_literal_match(trimmed, path, ctx);
1302    }
1303
1304    let nwl = simple_length(trimmed);
1305    let flags = 0u32;
1306    if nwl == trimmed.len() {
1307        return wildmatch(trimmed.as_bytes(), path.as_bytes(), flags);
1308    }
1309    let lit = trimmed.as_bytes().get(..nwl).unwrap_or_default();
1310    let path_b = path.as_bytes();
1311    if path_b.len() < nwl {
1312        return false;
1313    }
1314    if &path_b[..nwl] != lit {
1315        return false;
1316    }
1317    let pat_rest = &trimmed[nwl..];
1318    let path_rest = &path[nwl..];
1319    wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), flags)
1320}
1321
1322/// Match a pathspec against a tree path, using `.gitattributes` for `:(attr:...)`.
1323///
1324/// Used by `git archive` style tree walks: `mode` supplies directory/gitlink context for
1325/// literal pathspecs ending in `/`.
1326#[must_use]
1327pub fn matches_pathspec_for_object(
1328    spec: &str,
1329    path: &str,
1330    mode: u32,
1331    attr_rules: &[AttrRule],
1332) -> bool {
1333    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1334    let mut magic = combine_magic(elem_magic);
1335    magic.exclude = false;
1336
1337    if magic.literal && magic.glob {
1338        return false;
1339    }
1340
1341    let ctx = context_from_mode_bits(mode);
1342    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1343
1344    if !magic.attr_requirements.is_empty()
1345        && !attr_requirements_match(
1346            &magic.attr_requirements,
1347            attr_rules,
1348            path,
1349            is_dir_for_attr,
1350            mode,
1351        )
1352    {
1353        return false;
1354    }
1355
1356    let pattern = strip_top_magic(raw_pattern);
1357    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1358        if !path.starts_with(prefix) {
1359            return false;
1360        }
1361        &path[prefix.len()..]
1362    } else {
1363        path
1364    };
1365    if magic.literal || magic.glob || magic.icase {
1366        pathspec_matches_tail(pattern, path_for_match, magic)
1367    } else {
1368        matches_pathspec_with_context(pattern, path_for_match, ctx)
1369    }
1370}
1371
1372/// Returns wildmatch flags for `:(icase)` / `:(glob)`-style patterns when those
1373/// appear as explicit magic (not used by default CLI pathspecs).
1374#[must_use]
1375pub fn wildmatch_flags_icase_glob(icase: bool, glob: bool) -> u32 {
1376    let mut f = if glob { WM_PATHNAME } else { 0 };
1377    if icase {
1378        f |= WM_CASEFOLD;
1379    }
1380    f
1381}
1382
1383#[cfg(test)]
1384mod tree_entry_pathspec_tests {
1385    use super::*;
1386
1387    #[test]
1388    fn t6130_bracket_filename_matches_pathspec() {
1389        assert!(matches_pathspec("f[o][o]", "f[o][o]"));
1390        assert!(matches_pathspec(":(glob)f[o][o]", "f[o][o]"));
1391    }
1392
1393    #[test]
1394    fn literal_prefix_and_exact() {
1395        assert!(matches_pathspec("path1", "path1/file1"));
1396        assert!(matches_pathspec_with_context(
1397            "path1/",
1398            "path1/file1",
1399            PathspecMatchContext::default()
1400        ));
1401        assert!(matches_pathspec("file0", "file0"));
1402        assert!(!matches_pathspec("path", "path1/file1"));
1403    }
1404
1405    #[test]
1406    fn ls_tree_bracket_in_name_is_literal_prefix() {
1407        assert!(matches_ls_tree_pathspec(
1408            "a[a]",
1409            "a[a]/three",
1410            0o100644,
1411            &[]
1412        ));
1413        assert!(!matches_pathspec_with_context(
1414            "a[a]",
1415            "a[a]/three",
1416            PathspecMatchContext::default()
1417        ));
1418    }
1419
1420    #[test]
1421    fn wildcards_cross_slash_by_default() {
1422        assert!(matches_pathspec("f*", "file0"));
1423        assert!(matches_pathspec("*file1", "path1/file1"));
1424        assert!(matches_pathspec_with_context(
1425            "path1/f*",
1426            "path1",
1427            PathspecMatchContext {
1428                is_directory: true,
1429                ..Default::default()
1430            }
1431        ));
1432        assert!(matches_pathspec("path1/*file1", "path1/file1"));
1433    }
1434
1435    #[test]
1436    fn glob_double_star_txt_at_repo_root() {
1437        assert!(pathspec_matches(":(glob)**/*.txt", "untracked.txt"));
1438        assert!(pathspec_matches(":(glob)**/*.txt", "d/untracked.txt"));
1439    }
1440
1441    #[test]
1442    fn trailing_slash_directory_only() {
1443        assert!(!matches_pathspec_with_context(
1444            "file0/",
1445            "file0",
1446            PathspecMatchContext::default()
1447        ));
1448        assert!(matches_pathspec_with_context(
1449            "file0/",
1450            "file0",
1451            PathspecMatchContext {
1452                is_directory: true,
1453                ..Default::default()
1454            }
1455        ));
1456        assert!(matches_pathspec_with_context(
1457            "submod/",
1458            "submod",
1459            PathspecMatchContext {
1460                is_git_submodule: true,
1461                ..Default::default()
1462            }
1463        ));
1464    }
1465
1466    #[test]
1467    fn exclude_top_short_magic_subtracts_from_positive() {
1468        let specs = vec!["*".to_string(), ":/!sub2".to_string()];
1469        assert!(matches_pathspec_list("sub/file", &specs));
1470        assert!(!matches_pathspec_list("sub2/file", &specs));
1471        assert!(pathspec_exclude_matches(":/!sub2", "sub2/file"));
1472    }
1473}
1474
1475#[cfg(test)]
1476mod pathspec_list_tests {
1477    use super::*;
1478    use crate::crlf::parse_gitattributes_content;
1479
1480    #[test]
1481    fn exclude_removes_paths_matching_icase_positive() {
1482        let specs = vec![
1483            ":(icase)*.txt".to_string(),
1484            ":(exclude)submodule/subsub/*".to_string(),
1485        ];
1486        assert!(path_allowed_by_pathspec_list(&specs, "submodule/g.txt"));
1487        assert!(!path_allowed_by_pathspec_list(
1488            &specs,
1489            "submodule/subsub/e.txt"
1490        ));
1491    }
1492
1493    #[test]
1494    fn prefixed_attr_exclude_removes_matching_child_path() {
1495        let specs = vec![
1496            "sub".to_string(),
1497            ":(exclude,attr:labelB,prefix:sub/)".to_string(),
1498        ];
1499        let exclude_only = vec![":(exclude,attr:labelB,prefix:sub/)".to_string()];
1500        let attrs = parse_gitattributes_content("fileB labelB\n");
1501        assert!(!matches_pathspec_list_for_object(
1502            "sub/fileB",
1503            0o100644,
1504            &attrs,
1505            &specs,
1506        ));
1507        assert!(!matches_pathspec_list_for_object(
1508            "sub/fileB",
1509            0o100644,
1510            &attrs,
1511            &exclude_only,
1512        ));
1513    }
1514}