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;
9use std::path::{Path, PathBuf};
10
11use crate::crlf::path_gitattribute_value;
12use crate::crlf::AttrRule;
13use crate::error::{Error, Result as LibResult};
14use crate::precompose_config::pathspec_precompose_enabled;
15use crate::unicode_normalization::precompose_utf8_path;
16use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
17
18/// Returns the length of the leading literal segment before the first glob metacharacter,
19/// matching Git's `simple_length()` (`*` `?` `[` `\`) on bytes.
20#[must_use]
21pub fn simple_length(match_str: &str) -> usize {
22    let b = match_str.as_bytes();
23    let mut len = 0usize;
24    for &c in b {
25        if matches!(c, b'*' | b'?' | b'[' | b'\\') {
26            break;
27        }
28        len += 1;
29    }
30    len
31}
32
33/// Whether the pattern uses wildcards after Git pathspec escaping rules.
34#[must_use]
35pub fn has_glob_chars(s: &str) -> bool {
36    simple_length(s) < s.len()
37}
38
39/// Read pathspec entries from raw file bytes (stdin or file), matching Git's
40/// `--pathspec-from-file` / `--pathspec-file-nul` rules.
41///
42/// * **NUL mode:** entries are separated by `NUL`; each segment must not use
43///   C-style quoted lines (Git rejects quoted pathspecs in this mode).
44/// * **Line mode:** entries are separated by `LF`; optional `CR` before `LF`
45///   is stripped; optional trailing line without a final newline is included;
46///   double-quoted lines are C-unquoted (including octal escapes).
47pub fn parse_pathspecs_from_source(data: &[u8], nul_terminated: bool) -> LibResult<Vec<String>> {
48    if nul_terminated {
49        let mut out = Vec::new();
50        for chunk in data.split(|b| *b == 0) {
51            if chunk.is_empty() {
52                continue;
53            }
54            let s = String::from_utf8_lossy(chunk);
55            let t = s.trim();
56            if t.starts_with('"') {
57                return Err(Error::PathError(format!(
58                    "pathspec-from-file: line is not NUL terminated: {t}"
59                )));
60            }
61            out.push(t.to_string());
62        }
63        return Ok(out);
64    }
65
66    let text = String::from_utf8_lossy(data);
67    let mut out = Vec::new();
68    for raw in text.split_inclusive('\n') {
69        let line = raw.trim_end_matches('\n').trim_end_matches('\r');
70        if line.is_empty() {
71            continue;
72        }
73        if line.starts_with('"') && line.ends_with('"') && line.len() >= 2 {
74            out.push(unquote_c_style_pathspec_line(line)?);
75        } else {
76            out.push(line.to_string());
77        }
78    }
79    Ok(out)
80}
81
82/// Unquote a single `--pathspec-from-file` line that is wrapped in double quotes.
83fn unquote_c_style_pathspec_line(s: &str) -> LibResult<String> {
84    let bytes = s.as_bytes();
85    if bytes.first() != Some(&b'"') || bytes.last() != Some(&b'"') || bytes.len() < 2 {
86        return Err(Error::PathError(format!("invalid C-style quoting: {s}")));
87    }
88
89    let inner = &bytes[1..bytes.len() - 1];
90    let mut out = Vec::with_capacity(inner.len());
91    let mut i = 0;
92    while i < inner.len() {
93        if inner[i] != b'\\' {
94            out.push(inner[i]);
95            i += 1;
96            continue;
97        }
98        i += 1;
99        if i >= inner.len() {
100            return Err(Error::PathError(
101                "invalid escape at end of string".to_string(),
102            ));
103        }
104        match inner[i] {
105            b'\\' => out.push(b'\\'),
106            b'"' => out.push(b'"'),
107            b'a' => out.push(7),
108            b'b' => out.push(8),
109            b'f' => out.push(12),
110            b'n' => out.push(b'\n'),
111            b'r' => out.push(b'\r'),
112            b't' => out.push(b'\t'),
113            b'v' => out.push(11),
114            c if c.is_ascii_digit() => {
115                if i + 2 >= inner.len() {
116                    return Err(Error::PathError("truncated octal escape".to_string()));
117                }
118                let oct = std::str::from_utf8(&inner[i..i + 3])
119                    .map_err(|_| Error::PathError("invalid octal bytes".to_string()))?;
120                out.push(
121                    u8::from_str_radix(oct, 8)
122                        .map_err(|_| Error::PathError("invalid octal escape value".to_string()))?,
123                );
124                i += 2;
125            }
126            other => {
127                return Err(Error::PathError(format!(
128                    "invalid escape sequence \\{}",
129                    char::from(other)
130                )));
131            }
132        }
133        i += 1;
134    }
135    String::from_utf8(out).map_err(|_| Error::PathError("invalid UTF-8 in quoted pathspec".into()))
136}
137
138#[derive(Debug, Clone, Default)]
139struct PathspecMagic {
140    literal: bool,
141    glob: bool,
142    icase: bool,
143    exclude: bool,
144    /// `:(top)` / short `:/` — paths are relative to repo root.
145    top: bool,
146    prefix: Option<String>,
147    /// `:(attr:...)` requirements.
148    attr_requirements: Vec<AttrRequirement>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152enum AttrRequirement {
153    Set(String),
154    Unset(String),
155    Unspecified(String),
156    Value(String, String),
157}
158
159impl AttrRequirement {
160    fn name(&self) -> &str {
161        match self {
162            AttrRequirement::Set(name)
163            | AttrRequirement::Unset(name)
164            | AttrRequirement::Unspecified(name)
165            | AttrRequirement::Value(name, _) => name,
166        }
167    }
168}
169
170fn parse_maybe_bool(v: &str) -> Option<bool> {
171    let s = v.trim().to_ascii_lowercase();
172    match s.as_str() {
173        "true" | "yes" | "on" | "1" => Some(true),
174        "false" | "no" | "off" | "0" => Some(false),
175        _ => None,
176    }
177}
178
179fn git_env_bool(key: &str, default: bool) -> bool {
180    match std::env::var(key) {
181        Ok(v) => parse_maybe_bool(&v).unwrap_or(default),
182        Err(_) => default,
183    }
184}
185
186fn literal_global() -> bool {
187    git_env_bool("GIT_LITERAL_PATHSPECS", false)
188}
189
190/// Whether `GIT_LITERAL_PATHSPECS` is enabled (shell `*` and `?` are literal, not globs).
191#[must_use]
192pub fn literal_pathspecs_enabled() -> bool {
193    literal_global()
194}
195
196fn glob_global() -> bool {
197    git_env_bool("GIT_GLOB_PATHSPECS", false)
198}
199
200fn noglob_global() -> bool {
201    git_env_bool("GIT_NOGLOB_PATHSPECS", false)
202}
203
204fn icase_global() -> bool {
205    git_env_bool("GIT_ICASE_PATHSPECS", false)
206}
207
208/// Validates global pathspec environment flags the same way Git does.
209///
210/// Returns an error message suitable for `bail!` when flags are incompatible.
211pub fn validate_global_pathspec_flags() -> Result<(), String> {
212    let lit = literal_global();
213    let glob = glob_global();
214    let noglob = noglob_global();
215    let icase = icase_global();
216
217    if glob && noglob {
218        return Err("global 'glob' and 'noglob' pathspec settings are incompatible".to_string());
219    }
220    if lit && (glob || noglob || icase) {
221        return Err(
222            "global 'literal' pathspec setting is incompatible with all other global pathspec settings"
223                .to_string(),
224        );
225    }
226    Ok(())
227}
228
229fn is_valid_attr_name(name: &str) -> bool {
230    !name.is_empty()
231        && name
232            .bytes()
233            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
234}
235
236fn split_attr_expr(expr: &str) -> Result<Vec<String>, String> {
237    let mut parts = Vec::new();
238    let mut cur = String::new();
239    let mut in_value = false;
240    let mut escaped = false;
241
242    for ch in expr.chars() {
243        if escaped {
244            if ch.is_ascii_whitespace() {
245                return Err(
246                    "Escape character '\\' not allowed as last character in attr value".to_string(),
247                );
248            }
249            if ch != ',' {
250                return Err("Escape character '\\' not allowed for value matching".to_string());
251            }
252            cur.push(ch);
253            escaped = false;
254            continue;
255        }
256        if in_value && ch == '\\' {
257            escaped = true;
258            continue;
259        }
260        if ch == '=' {
261            in_value = true;
262            cur.push(ch);
263            continue;
264        }
265        if ch.is_ascii_whitespace() {
266            if !cur.is_empty() {
267                parts.push(cur);
268                cur = String::new();
269            }
270            in_value = false;
271            continue;
272        }
273        cur.push(ch);
274    }
275
276    if escaped {
277        return Err(
278            "Escape character '\\' not allowed as last character in attr value".to_string(),
279        );
280    }
281    if !cur.is_empty() {
282        parts.push(cur);
283    }
284    Ok(parts)
285}
286
287fn parse_attr_requirements(expr: &str) -> Result<Vec<AttrRequirement>, String> {
288    if expr.trim().is_empty() {
289        return Err("empty attr magic is invalid".to_string());
290    }
291    let mut out = Vec::new();
292    for token in split_attr_expr(expr)? {
293        if let Some(name) = token.strip_prefix('-') {
294            if name.contains('=') {
295                return Err("invalid attribute name".to_string());
296            }
297            if !is_valid_attr_name(name) {
298                return Err(format!("{name} is not a valid attribute name"));
299            }
300            out.push(AttrRequirement::Unset(name.to_string()));
301        } else if let Some(name) = token.strip_prefix('!') {
302            if name.contains('=') {
303                return Err("invalid attribute name".to_string());
304            }
305            if !is_valid_attr_name(name) {
306                return Err(format!("{name} is not a valid attribute name"));
307            }
308            out.push(AttrRequirement::Unspecified(name.to_string()));
309        } else if let Some((name, value)) = token.split_once('=') {
310            if !is_valid_attr_name(name) {
311                return Err(format!("{name} is not a valid attribute name"));
312            }
313            if value.is_empty() {
314                return Err("empty attribute value is not allowed".to_string());
315            }
316            out.push(AttrRequirement::Value(name.to_string(), value.to_string()));
317        } else {
318            if !is_valid_attr_name(&token) {
319                return Err(format!("{token} is not a valid attribute name"));
320            }
321            out.push(AttrRequirement::Set(token));
322        }
323    }
324    if out.is_empty() {
325        return Err("empty attr magic is invalid".to_string());
326    }
327    Ok(out)
328}
329
330/// Validate `:(attr:...)` pathspec magic in `specs`.
331///
332/// Returns `Ok(())` when all attribute magic is parseable. Returns a Git-style error string for
333/// unsupported or malformed attribute magic.
334pub fn validate_attr_pathspecs(specs: &[String]) -> Result<(), String> {
335    for spec in specs {
336        if literal_global() || !spec.starts_with(":(") {
337            continue;
338        }
339        let Some(rest) = spec.strip_prefix(":(") else {
340            continue;
341        };
342        let Some(close) = rest.find(')') else {
343            continue;
344        };
345        let magic_part = &rest[..close];
346        let mut attr_count = 0usize;
347        for token in split_long_magic_tokens(magic_part) {
348            let Some(expr) = token.trim().strip_prefix("attr:") else {
349                continue;
350            };
351            attr_count += 1;
352            if attr_count > 1 {
353                return Err("Only one 'attr:' specification is allowed.".to_string());
354            }
355            parse_attr_requirements(expr)?;
356        }
357    }
358    Ok(())
359}
360
361fn split_long_magic_tokens(magic_part: &str) -> Vec<String> {
362    let mut tokens = Vec::new();
363    let mut cur = String::new();
364    let mut escaped = false;
365    for ch in magic_part.chars() {
366        if escaped {
367            cur.push('\\');
368            cur.push(ch);
369            escaped = false;
370            continue;
371        }
372        if ch == '\\' {
373            escaped = true;
374            continue;
375        }
376        if ch == ',' {
377            tokens.push(cur.trim().to_string());
378            cur.clear();
379            continue;
380        }
381        cur.push(ch);
382    }
383    if escaped {
384        cur.push('\\');
385    }
386    tokens.push(cur.trim().to_string());
387    tokens
388}
389
390fn parse_long_magic(rest_after_paren: &str) -> Option<(PathspecMagic, &str)> {
391    let close = rest_after_paren.find(')')?;
392    let magic_part = &rest_after_paren[..close];
393    let tail = &rest_after_paren[close + 1..];
394    let mut magic = PathspecMagic::default();
395    for raw in split_long_magic_tokens(magic_part) {
396        let token = raw.trim();
397        if token.is_empty() {
398            continue;
399        }
400        if let Some(p) = token.strip_prefix("prefix:") {
401            magic.prefix = Some(p.to_string());
402            continue;
403        }
404        if let Some(expr) = token.strip_prefix("attr:") {
405            if let Ok(reqs) = parse_attr_requirements(expr) {
406                magic.attr_requirements = reqs;
407            }
408            continue;
409        }
410        if token.eq_ignore_ascii_case("literal") {
411            magic.literal = true;
412        } else if token.eq_ignore_ascii_case("glob") {
413            magic.glob = true;
414        } else if token.eq_ignore_ascii_case("icase") {
415            magic.icase = true;
416        } else if token.eq_ignore_ascii_case("exclude") {
417            magic.exclude = true;
418        } else if token.eq_ignore_ascii_case("top") {
419            magic.top = true;
420        }
421    }
422    Some((magic, tail))
423}
424
425/// `elem` is the full pathspec beginning with `:` (short magic form, not `:(...)`).
426fn parse_short_magic(elem: &str) -> (PathspecMagic, &str) {
427    let bytes = elem.as_bytes();
428    let mut i = 1usize;
429    let mut magic = PathspecMagic::default();
430    while i < bytes.len() && bytes[i] != b':' {
431        let ch = bytes[i];
432        if ch == b'^' {
433            magic.exclude = true;
434            i += 1;
435            continue;
436        }
437        let is_magic = match ch {
438            b'!' => {
439                magic.exclude = true;
440                true
441            }
442            b'/' => {
443                magic.top = true;
444                true
445            } // short `:/` = top
446            _ => false,
447        };
448        if is_magic {
449            i += 1;
450            continue;
451        }
452        break;
453    }
454    if i < bytes.len() && bytes[i] == b':' {
455        i += 1;
456    }
457    (magic, &elem[i..])
458}
459
460/// Strip `:(magic)` / `:magic` prefix when not in literal-global mode.
461fn parse_element_magic(elem: &str) -> (PathspecMagic, &str) {
462    if !elem.starts_with(':') || literal_global() {
463        return (PathspecMagic::default(), elem);
464    }
465    if let Some(rest) = elem.strip_prefix(":(") {
466        return parse_long_magic(rest).unwrap_or((PathspecMagic::default(), elem));
467    }
468    parse_short_magic(elem)
469}
470
471fn combine_magic(element: PathspecMagic) -> PathspecMagic {
472    let mut m = element;
473    if literal_global() {
474        m.literal = true;
475    }
476    if glob_global() && !m.literal {
477        m.glob = true;
478    }
479    if icase_global() {
480        m.icase = true;
481    }
482    if noglob_global() && !m.glob {
483        m.literal = true;
484    }
485    m
486}
487
488fn strip_top_magic(mut pattern: &str) -> &str {
489    if let Some(r) = pattern.strip_prefix(":/") {
490        pattern = r;
491    }
492    pattern
493}
494
495/// Path prefix used for Bloom-filter lookups (`revision.c` `convert_pathspec_to_bloom_keyvec`).
496///
497/// `cwd_from_repo_root` is the path from the repository work tree to the process cwd, using `/`
498/// separators and no leading slash (empty string at repo root). Used for `:(top)` / `:/`.
499#[must_use]
500pub fn bloom_lookup_prefix_with_cwd(
501    spec: &str,
502    cwd_from_repo_root: Option<&str>,
503) -> Option<String> {
504    let (elem_magic, raw_pattern) = parse_element_magic(spec);
505    let magic = combine_magic(elem_magic);
506    if magic.exclude || magic.icase {
507        return None;
508    }
509    let pattern = strip_top_magic(raw_pattern);
510    if pattern.is_empty() {
511        return None;
512    }
513    let combined = if magic.top {
514        let cwd = cwd_from_repo_root.unwrap_or("").trim_end_matches('/');
515        if cwd.is_empty() {
516            pattern.to_string()
517        } else {
518            format!("{cwd}/{pattern}")
519        }
520    } else {
521        pattern.to_string()
522    };
523    let pattern = combined.as_str();
524    let mut len = simple_length(pattern);
525    if len != pattern.len() {
526        while len > 0 && pattern.as_bytes()[len - 1] != b'/' {
527            len -= 1;
528        }
529    }
530    while len > 0 && pattern.as_bytes()[len - 1] == b'/' {
531        len -= 1;
532    }
533    if len == 0 {
534        return None;
535    }
536    Some(combined[..len].to_string())
537}
538
539#[must_use]
540pub fn bloom_lookup_prefix(spec: &str) -> Option<String> {
541    bloom_lookup_prefix_with_cwd(spec, None)
542}
543
544/// Whether every pathspec can participate in Bloom precomputation (Git `forbid_bloom_filters`).
545#[must_use]
546pub fn pathspecs_allow_bloom(specs: &[String]) -> bool {
547    specs.iter().all(|s| {
548        !s.is_empty() && !pathspec_is_exclude(s) && bloom_lookup_prefix_with_cwd(s, None).is_some()
549    })
550}
551
552/// Whether `path` is included when Git applies a pathspec list with optional `:(exclude)` entries.
553///
554/// A path is rejected if any exclude pathspec matches it. When at least one non-exclude pathspec is
555/// present, the path must also match one of those positives (`OR` semantics).
556#[must_use]
557pub fn path_allowed_by_pathspec_list(specs: &[String], path: &str) -> bool {
558    let mut has_positive = false;
559    let mut positive_match = false;
560    for s in specs {
561        let (elem, raw_pattern) = parse_element_magic(s);
562        let magic = combine_magic(elem);
563        if magic.exclude {
564            if path_matches_pathspec_tail(raw_pattern, path, magic) {
565                return false;
566            }
567            continue;
568        }
569        has_positive = true;
570        if pathspec_matches(s, path) {
571            positive_match = true;
572        }
573    }
574    !has_positive || positive_match
575}
576
577/// True when `spec` matches `path` for pathspec bookkeeping (positive match or exclude hit).
578#[must_use]
579pub fn pathspec_contributes_match(spec: &str, path: &str) -> bool {
580    pathspec_matches(spec, path) || pathspec_exclude_matches(spec, path)
581}
582
583fn path_matches_pathspec_tail(raw_pattern: &str, path: &str, magic: PathspecMagic) -> bool {
584    if magic.literal && magic.glob {
585        return false;
586    }
587    let pattern = strip_top_magic(raw_pattern);
588    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
589        if !path.starts_with(prefix) {
590            return false;
591        }
592        &path[prefix.len()..]
593    } else {
594        path
595    };
596    pathspec_matches_tail(pattern, path_for_match, magic)
597}
598
599/// True if `path` is matched by `spec` (Git pathspec syntax, including magic and globals).
600///
601/// Same as [`matches_pathspec`] (default file context; exclude specs never match positively here).
602/// See [`matches_pathspec_list`].
603#[must_use]
604pub fn pathspec_matches(spec: &str, path: &str) -> bool {
605    matches_pathspec(spec, path)
606}
607
608/// Returns whether `spec` uses Git's exclude magic (`:(exclude)`, `:!`, `:^`, etc.).
609#[must_use]
610pub fn pathspec_is_exclude(spec: &str) -> bool {
611    let (elem_magic, _) = parse_element_magic(spec);
612    combine_magic(elem_magic).exclude
613}
614
615/// Whether tree-walking should recurse into directory `full_name` for pathspec `spec` without
616/// `-r` (Git `read_tree` / `show_recursive` “interesting” descent).
617///
618/// Exclude-only patterns never trigger descent alone.
619#[must_use]
620pub fn pathspec_wants_descent_into_tree(spec: &str, full_name: &str) -> bool {
621    if pathspec_is_exclude(spec) {
622        return false;
623    }
624    let (elem_magic, raw_pattern) = parse_element_magic(spec);
625    let magic = combine_magic(elem_magic);
626    if magic.exclude {
627        return false;
628    }
629    let pattern = strip_top_magic(raw_pattern);
630    let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
631    if pattern.is_empty() || pattern == "." {
632        return true;
633    }
634    let dir_prefix = format!("{full_name}/");
635    if pattern.starts_with(&dir_prefix) {
636        return true;
637    }
638    let probe = format!("{full_name}/.__grit_ls_tree_probe__");
639    matches_ls_tree_pathspec(spec, &probe, 0o100644, &[])
640}
641
642/// Like [`matches_pathspec_set_for_object`], but uses [`matches_ls_tree_pathspec`] for each
643/// element so `ls-files` / index filtering agrees with `ls-tree` on patterns such as `a[a]`.
644#[must_use]
645pub fn matches_pathspec_set_for_object_ls_tree(
646    specs: &[String],
647    path: &str,
648    mode: u32,
649    attr_rules: &[AttrRule],
650) -> bool {
651    if specs.is_empty() {
652        return true;
653    }
654    let mut positives: Vec<&str> = Vec::new();
655    let mut excludes: Vec<&str> = Vec::new();
656    for s in specs {
657        if pathspec_is_exclude(s) {
658            excludes.push(s.as_str());
659        } else {
660            positives.push(s.as_str());
661        }
662    }
663    let positive_ok = if positives.is_empty() {
664        true
665    } else {
666        positives
667            .iter()
668            .any(|s| matches_ls_tree_pathspec(s, path, mode, attr_rules))
669    };
670    if !positive_ok {
671        return false;
672    }
673    for ex in excludes {
674        if matches_ls_tree_pathspec(ex, path, mode, attr_rules) {
675            return false;
676        }
677    }
678    true
679}
680
681/// True if `path` matches the combined pathspec list: any positive spec (or all paths when there
682/// are only excludes, matching Git `parse_pathspec`), and not matched by any exclude spec.
683#[must_use]
684pub fn matches_pathspec_set_for_object(
685    specs: &[String],
686    path: &str,
687    mode: u32,
688    attr_rules: &[AttrRule],
689) -> bool {
690    if specs.is_empty() {
691        return true;
692    }
693    let mut positives: Vec<&str> = Vec::new();
694    let mut excludes: Vec<&str> = Vec::new();
695    for s in specs {
696        if pathspec_is_exclude(s) {
697            excludes.push(s.as_str());
698        } else {
699            positives.push(s.as_str());
700        }
701    }
702    let positive_ok = if positives.is_empty() {
703        true
704    } else {
705        positives
706            .iter()
707            .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
708    };
709    if !positive_ok {
710        return false;
711    }
712    for ex in excludes {
713        if matches_pathspec_for_object(ex, path, mode, attr_rules) {
714            return false;
715        }
716    }
717    true
718}
719
720/// True if `spec` uses `:(top)` or short `:/` (repo-root-relative) magic.
721#[must_use]
722pub fn pathspec_has_top(spec: &str) -> bool {
723    let (elem_magic, _) = parse_element_magic(spec);
724    combine_magic(elem_magic).top
725}
726
727fn pathspec_match_one_positive(path: &str, magic: PathspecMagic, raw_pattern: &str) -> bool {
728    if magic.literal && magic.glob {
729        return false;
730    }
731    let pattern = strip_top_magic(raw_pattern);
732    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
733        if !path.starts_with(prefix) {
734            return false;
735        }
736        &path[prefix.len()..]
737    } else {
738        path
739    };
740    pathspec_matches_tail(pattern, path_for_match, magic)
741}
742
743fn attr_requirements_match(
744    requirements: &[AttrRequirement],
745    attr_rules: &[AttrRule],
746    path: &str,
747    is_dir: bool,
748    mode: u32,
749) -> bool {
750    requirements.iter().all(|req| {
751        let value = if req.name() == "builtin_objectmode" {
752            if mode == 0 {
753                None
754            } else {
755                Some(format!("{mode:06o}"))
756            }
757        } else {
758            path_gitattribute_value(attr_rules, path, is_dir, req.name())
759        };
760        match req {
761            AttrRequirement::Set(_) => value.as_deref() == Some("set"),
762            AttrRequirement::Unset(_) => value.as_deref() == Some("unset"),
763            AttrRequirement::Unspecified(_) => value.is_none(),
764            AttrRequirement::Value(_, expected) => value.as_deref() == Some(expected.as_str()),
765        }
766    })
767}
768
769fn matches_pathspec_element_with_context(
770    spec: &str,
771    path: &str,
772    ctx: PathspecMatchContext,
773) -> bool {
774    let (elem_magic, raw_pattern) = parse_element_magic(spec);
775    let magic = combine_magic(elem_magic);
776    if magic.exclude {
777        return false;
778    }
779    if magic.literal && magic.glob {
780        return false;
781    }
782    if !magic.attr_requirements.is_empty() {
783        return false;
784    }
785    if magic.literal || magic.glob || magic.icase {
786        return pathspec_matches(spec, path);
787    }
788    let pattern = strip_top_magic(raw_pattern);
789    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
790        if !path.starts_with(prefix) {
791            return false;
792        }
793        &path[prefix.len()..]
794    } else {
795        path
796    };
797    matches_pathspec_with_context(pattern, path_for_match, ctx)
798}
799
800fn pathspec_exclude_element_matches_with_context(
801    spec: &str,
802    path: &str,
803    ctx: PathspecMatchContext,
804) -> bool {
805    let (elem_magic, raw_pattern) = parse_element_magic(spec);
806    let mut magic = combine_magic(elem_magic);
807    if !magic.exclude {
808        return false;
809    }
810    magic.exclude = false;
811    if magic.literal && magic.glob {
812        return false;
813    }
814    if !magic.attr_requirements.is_empty() {
815        // Attribute pathspecs need `.gitattributes` context; use
816        // [`matches_pathspec_list_for_object`] for those.
817        return false;
818    }
819    if magic.literal || magic.glob || magic.icase {
820        return pathspec_match_one_positive(path, magic, raw_pattern);
821    }
822    let pattern = strip_top_magic(raw_pattern);
823    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
824        if !path.starts_with(prefix) {
825            return false;
826        }
827        &path[prefix.len()..]
828    } else {
829        path
830    };
831    matches_pathspec_with_context(pattern, path_for_match, ctx)
832}
833
834/// True if `path` is matched by an exclude pathspec's pattern. Returns `false` if `spec` is not
835/// an exclude pathspec.
836#[must_use]
837pub fn pathspec_exclude_matches(spec: &str, path: &str) -> bool {
838    pathspec_exclude_element_matches_with_context(spec, path, PathspecMatchContext::default())
839}
840
841/// When every pathspec is an exclude and none use `:(top)` / `:/`, Git prepends an implicit
842/// positive that matches only under the process cwd (relative to the work tree), not the whole
843/// repository (`PATHSPEC_PREFER_CWD` in `pathspec.c`). `cwd_from_repo_root` is that prefix
844/// without a trailing slash, or empty at the work tree root.
845#[must_use]
846pub fn extend_pathspec_list_implicit_cwd(
847    specs: &[String],
848    cwd_from_repo_root: Option<&str>,
849) -> Vec<String> {
850    if specs.is_empty() {
851        return specs.to_vec();
852    }
853    if !specs.iter().all(|s| pathspec_is_exclude(s)) {
854        return specs.to_vec();
855    }
856    let any_top = specs.iter().any(|s| pathspec_has_top(s));
857    if any_top {
858        return specs.to_vec();
859    }
860    let Some(cwd) = cwd_from_repo_root.map(str::trim).filter(|s| !s.is_empty()) else {
861        return specs.to_vec();
862    };
863    let cwd = cwd.trim_end_matches('/');
864    if cwd.is_empty() {
865        return specs.to_vec();
866    }
867    let mut out = Vec::with_capacity(specs.len() + 1);
868    out.push(format!("{cwd}/"));
869    out.extend_from_slice(specs);
870    out
871}
872
873/// Git `match_pathspec` semantics over a pathspec list: OR of positive specs minus OR of exclude
874/// specs. If every element is exclude-only, Git implicitly prepends `.` (match all); this
875/// function does the same.
876#[must_use]
877pub fn matches_pathspec_list(path: &str, specs: &[String]) -> bool {
878    matches_pathspec_list_with_context(path, specs, PathspecMatchContext::default())
879}
880
881/// Like [`matches_pathspec_list`], but uses `ctx` for non-magic pathspec elements (trailing `/`).
882#[must_use]
883pub fn matches_pathspec_list_with_context(
884    path: &str,
885    specs: &[String],
886    ctx: PathspecMatchContext,
887) -> bool {
888    if specs.is_empty() {
889        return true;
890    }
891    let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
892    let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
893    let positive = if positive_specs.is_empty() {
894        true
895    } else {
896        positive_specs
897            .iter()
898            .any(|s| matches_pathspec_element_with_context(s, path, ctx))
899    };
900    if !positive {
901        return false;
902    }
903    if !has_exclude {
904        return true;
905    }
906    let excluded = specs.iter().any(|s| {
907        pathspec_is_exclude(s) && pathspec_exclude_element_matches_with_context(s, path, ctx)
908    });
909    !excluded
910}
911
912/// `matches_pathspec_list` for tree/index objects with mode and `.gitattributes` rules.
913#[must_use]
914pub fn matches_pathspec_list_for_object(
915    path: &str,
916    mode: u32,
917    attr_rules: &[AttrRule],
918    specs: &[String],
919) -> bool {
920    if specs.is_empty() {
921        return true;
922    }
923    let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
924    let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
925    let positive = if positive_specs.is_empty() {
926        true
927    } else {
928        positive_specs
929            .iter()
930            .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
931    };
932    if !positive {
933        return false;
934    }
935    if !has_exclude {
936        return true;
937    }
938    let excluded = specs.iter().any(|s| {
939        pathspec_is_exclude(s) && matches_pathspec_exclude_for_object(s, path, mode, attr_rules)
940    });
941    !excluded
942}
943
944fn matches_pathspec_exclude_for_object(
945    spec: &str,
946    path: &str,
947    mode: u32,
948    attr_rules: &[AttrRule],
949) -> bool {
950    let (elem_magic, raw_pattern) = parse_element_magic(spec);
951    let mut magic = combine_magic(elem_magic);
952    if !magic.exclude {
953        return false;
954    }
955    magic.exclude = false;
956    if magic.literal && magic.glob {
957        return false;
958    }
959    let ctx = context_from_mode_bits(mode);
960    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
961    if !magic.attr_requirements.is_empty()
962        && !attr_requirements_match(
963            &magic.attr_requirements,
964            attr_rules,
965            path,
966            is_dir_for_attr,
967            mode,
968        )
969    {
970        return false;
971    }
972    let pattern = strip_top_magic(raw_pattern);
973    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
974        if !path.starts_with(prefix) {
975            return false;
976        }
977        &path[prefix.len()..]
978    } else {
979        path
980    };
981    if magic.literal || magic.glob || magic.icase {
982        pathspec_matches_tail(pattern, path_for_match, magic)
983    } else {
984        matches_pathspec_with_context(pattern, path_for_match, ctx)
985    }
986}
987
988fn pathspec_matches_tail(pattern: &str, path: &str, magic: PathspecMagic) -> bool {
989    if pattern.is_empty() {
990        return true;
991    }
992
993    let flags = if magic.icase { WM_CASEFOLD } else { 0 };
994
995    if magic.literal {
996        return literal_prefix_match(pattern, path);
997    }
998
999    let wm_flags = if magic.glob {
1000        flags | WM_PATHNAME
1001    } else {
1002        flags
1003    };
1004
1005    let pattern_bytes = pattern.as_bytes();
1006    let path_bytes = path.as_bytes();
1007    let simple = simple_length(pattern);
1008
1009    // Git `match_pathspec_item`: exact / directory prefix before `git_fnmatch`.
1010    // Only when the pattern has no glob metacharacters (`simple_length` spans the whole pattern);
1011    // otherwise a pattern like `a[a]` must not match children via `a[a]/` prefix (t6130 vs ls-tree).
1012    if ps_str_eq(pattern, path, magic.icase) {
1013        return true;
1014    }
1015    if simple == pattern.len() {
1016        if let Some(prefix) = pattern.strip_suffix('/') {
1017            if ps_str_eq(prefix, path, magic.icase) {
1018                return true;
1019            }
1020            let prefix_slash = format!("{prefix}/");
1021            if path_starts_with(path, &prefix_slash, magic.icase) {
1022                return true;
1023            }
1024        } else {
1025            let prefix_slash = format!("{pattern}/");
1026            if path_starts_with(path, &prefix_slash, magic.icase) {
1027                return true;
1028            }
1029        }
1030    }
1031
1032    // `:(glob)**/*.txt` at repo root: Git matches `untracked.txt` (leading `**/` is optional).
1033    if magic.glob && !path.contains('/') && pattern.starts_with("**/") {
1034        if wildmatch(pattern_bytes, path_bytes, wm_flags) {
1035            return true;
1036        }
1037        if let Some(suffix) = pattern.strip_prefix("**/") {
1038            if wildmatch(suffix.as_bytes(), path_bytes, wm_flags) {
1039                return true;
1040            }
1041        }
1042    }
1043
1044    // Wildcard: require literal bytes up to `simple_length`, then wildmatch the tail only.
1045    if simple < pattern.len() {
1046        if path_bytes.len() < simple {
1047            return false;
1048        }
1049        let path_lit = &path_bytes[..simple];
1050        let pat_lit = &pattern_bytes[..simple];
1051        let same = if magic.icase {
1052            path_lit.eq_ignore_ascii_case(pat_lit)
1053        } else {
1054            path_lit == pat_lit
1055        };
1056        if !same {
1057            return false;
1058        }
1059        let pat_rest = &pattern[simple..];
1060        let path_rest = &path[simple..];
1061        return wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), wm_flags);
1062    }
1063
1064    ps_str_eq(pattern, path, magic.icase)
1065        || path_starts_with(path, &format!("{pattern}/"), magic.icase)
1066}
1067
1068fn ps_str_eq(a: &str, b: &str, icase: bool) -> bool {
1069    if icase {
1070        a.eq_ignore_ascii_case(b)
1071    } else {
1072        a == b
1073    }
1074}
1075
1076fn path_starts_with(path: &str, prefix: &str, icase: bool) -> bool {
1077    if icase {
1078        path.get(..prefix.len())
1079            .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
1080    } else {
1081        path.starts_with(prefix)
1082    }
1083}
1084
1085fn literal_prefix_match(pattern: &str, path: &str) -> bool {
1086    if let Some(prefix) = pattern.strip_suffix('/') {
1087        return path == prefix || path.starts_with(&format!("{prefix}/"));
1088    }
1089    path == pattern || path.starts_with(&format!("{pattern}/"))
1090}
1091
1092/// Literal pathspec match for `ls-tree` when the pattern has no `*`/`?` (brackets stay literal).
1093fn ls_tree_literal_match(pattern: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1094    if let Some(prefix) = pattern.strip_suffix('/') {
1095        if path.starts_with(&format!("{prefix}/")) {
1096            return true;
1097        }
1098        if path == prefix {
1099            return ctx.is_directory || ctx.is_git_submodule;
1100        }
1101        return false;
1102    }
1103    path == pattern || path.starts_with(&format!("{pattern}/"))
1104}
1105
1106/// Optional path metadata for literal pathspecs with a trailing `/` (tree-walk / diff-tree).
1107///
1108/// Git treats `dir/` as “directory or git submodule only”: a regular file `dir`
1109/// does not match, but a tree entry `dir` or gitlink `dir` does.
1110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1111pub struct PathspecMatchContext {
1112    /// The index/tree entry is a directory (mode `040000`).
1113    pub is_directory: bool,
1114    /// The entry is a git submodule / gitlink (`160000`).
1115    pub is_git_submodule: bool,
1116}
1117
1118/// Returns whether `path` matches the pathspec `spec` with default (file) context.
1119///
1120/// For pathspecs ending in `/`, a path equal to the prefix matches only when
1121/// [`PathspecMatchContext`] indicates a directory or submodule; see
1122/// [`matches_pathspec_with_context`].
1123#[must_use]
1124pub fn matches_pathspec(spec: &str, path: &str) -> bool {
1125    matches_pathspec_with_context(spec, path, PathspecMatchContext::default())
1126}
1127
1128/// Like [`matches_pathspec`], but uses `ctx` for trailing-`/` literal pathspecs and for
1129/// wildcard pathspecs where the pattern continues after a directory boundary (Git
1130/// `matches_pathspec` + directory semantics).
1131#[must_use]
1132pub fn matches_pathspec_with_context(spec: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1133    let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1134        precompose_utf8_path(spec)
1135    } else {
1136        Cow::Borrowed(spec)
1137    };
1138    let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1139        precompose_utf8_path(path)
1140    } else {
1141        Cow::Borrowed(path)
1142    };
1143    let spec = spec_nfc.as_ref();
1144    let path = path_nfc.as_ref();
1145
1146    let trimmed = spec.strip_prefix("./").unwrap_or(spec);
1147    if trimmed == "." || trimmed.is_empty() {
1148        return true;
1149    }
1150
1151    let (elem_magic, raw_pattern) = parse_element_magic(trimmed);
1152    let magic = combine_magic(elem_magic);
1153
1154    if magic.literal && magic.glob {
1155        return false;
1156    }
1157    if magic.exclude {
1158        return false;
1159    }
1160
1161    let pattern = strip_top_magic(raw_pattern);
1162    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1163        if !path.starts_with(prefix) {
1164            return false;
1165        }
1166        &path[prefix.len()..]
1167    } else {
1168        path
1169    };
1170
1171    if magic.literal {
1172        if let Some(prefix) = pattern.strip_suffix('/') {
1173            if path_for_match.starts_with(&format!("{prefix}/")) {
1174                return true;
1175            }
1176            if path_for_match == prefix {
1177                return ctx.is_directory || ctx.is_git_submodule;
1178            }
1179            return false;
1180        }
1181        return path_for_match == pattern || path_for_match.starts_with(&format!("{pattern}/"));
1182    }
1183
1184    // No wildcards and trailing `/`: directory-only semantics (Git `matches_pathspec`).
1185    if let Some(prefix) = pattern.strip_suffix('/') {
1186        if simple_length(pattern) == pattern.len() {
1187            if path_for_match.starts_with(&format!("{prefix}/")) {
1188                return true;
1189            }
1190            if path_for_match == prefix {
1191                return ctx.is_directory || ctx.is_git_submodule;
1192            }
1193            return false;
1194        }
1195    }
1196
1197    if pathspec_matches_tail(pattern, path_for_match, magic) {
1198        return true;
1199    }
1200
1201    if (ctx.is_directory || ctx.is_git_submodule)
1202        && !path_for_match.is_empty()
1203        && pattern.len() > path_for_match.len()
1204        && pattern.as_bytes().get(path_for_match.len()) == Some(&b'/')
1205        && pattern.starts_with(path_for_match)
1206        && simple_length(pattern) < pattern.len()
1207    {
1208        return true;
1209    }
1210
1211    false
1212}
1213
1214/// Parse a Git mode string (e.g. `100644`, `040000`) into a [`PathspecMatchContext`].
1215#[must_use]
1216pub fn context_from_mode_octal(mode: &str) -> PathspecMatchContext {
1217    let Ok(bits) = u32::from_str_radix(mode, 8) else {
1218        return PathspecMatchContext::default();
1219    };
1220    context_from_mode_bits(bits)
1221}
1222
1223/// Classify a raw Git mode (e.g. from an index or tree entry) for pathspec matching.
1224#[must_use]
1225pub fn context_from_mode_bits(mode: u32) -> PathspecMatchContext {
1226    let ty = mode & 0o170000;
1227    PathspecMatchContext {
1228        is_directory: ty == 0o040000,
1229        is_git_submodule: ty == 0o160000,
1230    }
1231}
1232
1233/// Pathspec matching for `ls-tree` after Git forces `pathspec.has_wildcard = 0` (`ls-tree.c`).
1234///
1235/// Metacharacters `*` / `?` still participate in [`wildmatch`]; `[` and `\\` are **not** glob
1236/// starters unless a `*` or `?` appears — so `a[a]` matches the literal directory `a[a]` (t3102),
1237/// while `a*` matches `a/one`, `aa/two`, `a[a]/three`, …
1238#[must_use]
1239pub fn matches_ls_tree_pathspec(
1240    spec: &str,
1241    path: &str,
1242    mode: u32,
1243    attr_rules: &[AttrRule],
1244) -> bool {
1245    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1246    let mut magic = combine_magic(elem_magic);
1247    magic.exclude = false;
1248
1249    if magic.literal && magic.glob {
1250        return false;
1251    }
1252
1253    let ctx = context_from_mode_bits(mode);
1254    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1255
1256    if !magic.attr_requirements.is_empty()
1257        && !attr_requirements_match(
1258            &magic.attr_requirements,
1259            attr_rules,
1260            path,
1261            is_dir_for_attr,
1262            mode,
1263        )
1264    {
1265        return false;
1266    }
1267
1268    let pattern = strip_top_magic(raw_pattern);
1269    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1270        if !path.starts_with(prefix) {
1271            return false;
1272        }
1273        &path[prefix.len()..]
1274    } else {
1275        path
1276    };
1277
1278    if magic.literal || magic.glob || magic.icase {
1279        return pathspec_matches_tail(pattern, path_for_match, magic);
1280    }
1281
1282    let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1283        precompose_utf8_path(pattern)
1284    } else {
1285        Cow::Borrowed(pattern)
1286    };
1287    let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1288        precompose_utf8_path(path_for_match)
1289    } else {
1290        Cow::Borrowed(path_for_match)
1291    };
1292    let pattern = spec_nfc.as_ref();
1293    let path = path_nfc.as_ref();
1294
1295    let trimmed = pattern.strip_prefix("./").unwrap_or(pattern);
1296    if trimmed == "." || trimmed.is_empty() {
1297        return true;
1298    }
1299
1300    let uses_star_or_question = trimmed.contains('*') || trimmed.contains('?');
1301    if !uses_star_or_question {
1302        return ls_tree_literal_match(trimmed, path, ctx);
1303    }
1304
1305    let nwl = simple_length(trimmed);
1306    let flags = 0u32;
1307    if nwl == trimmed.len() {
1308        return wildmatch(trimmed.as_bytes(), path.as_bytes(), flags);
1309    }
1310    let lit = trimmed.as_bytes().get(..nwl).unwrap_or_default();
1311    let path_b = path.as_bytes();
1312    if path_b.len() < nwl {
1313        return false;
1314    }
1315    if &path_b[..nwl] != lit {
1316        return false;
1317    }
1318    let pat_rest = &trimmed[nwl..];
1319    let path_rest = &path[nwl..];
1320    wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), flags)
1321}
1322
1323/// Match a pathspec against a tree path, using `.gitattributes` for `:(attr:...)`.
1324///
1325/// Used by `git archive` style tree walks: `mode` supplies directory/gitlink context for
1326/// literal pathspecs ending in `/`.
1327#[must_use]
1328pub fn matches_pathspec_for_object(
1329    spec: &str,
1330    path: &str,
1331    mode: u32,
1332    attr_rules: &[AttrRule],
1333) -> bool {
1334    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1335    let mut magic = combine_magic(elem_magic);
1336    magic.exclude = false;
1337
1338    if magic.literal && magic.glob {
1339        return false;
1340    }
1341
1342    let ctx = context_from_mode_bits(mode);
1343    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1344
1345    if !magic.attr_requirements.is_empty()
1346        && !attr_requirements_match(
1347            &magic.attr_requirements,
1348            attr_rules,
1349            path,
1350            is_dir_for_attr,
1351            mode,
1352        )
1353    {
1354        return false;
1355    }
1356
1357    let pattern = strip_top_magic(raw_pattern);
1358    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1359        if !path.starts_with(prefix) {
1360            return false;
1361        }
1362        &path[prefix.len()..]
1363    } else {
1364        path
1365    };
1366    if magic.literal || magic.glob || magic.icase {
1367        pathspec_matches_tail(pattern, path_for_match, magic)
1368    } else {
1369        matches_pathspec_with_context(pattern, path_for_match, ctx)
1370    }
1371}
1372
1373/// Returns wildmatch flags for `:(icase)` / `:(glob)`-style patterns when those
1374/// appear as explicit magic (not used by default CLI pathspecs).
1375#[must_use]
1376pub fn wildmatch_flags_icase_glob(icase: bool, glob: bool) -> u32 {
1377    let mut f = if glob { WM_PATHNAME } else { 0 };
1378    if icase {
1379        f |= WM_CASEFOLD;
1380    }
1381    f
1382}
1383
1384/// Resolved path lies outside the repository work tree (Git `prefix_path_gently` failure).
1385#[derive(Debug, Clone)]
1386pub struct PathOutsideRepository {
1387    /// User-facing pathspec token (argv element).
1388    pub elt: String,
1389    /// Resolved absolute path outside the work tree.
1390    pub path: String,
1391    /// Canonical work tree root.
1392    pub work_tree: PathBuf,
1393}
1394
1395impl std::fmt::Display for PathOutsideRepository {
1396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1397        write!(
1398            f,
1399            "fatal: {}: '{}' is outside repository at '{}'",
1400            self.elt,
1401            self.path,
1402            self.work_tree.display()
1403        )
1404    }
1405}
1406
1407/// Resolve a magic pathspec relative to a current-directory prefix.
1408///
1409/// This keeps the `cwd` prefix case-sensitive (via an internal `prefix:` magic
1410/// token) while still honoring magic options like `icase` for the tail.
1411/// Returns `None` when `spec` is not a parseable magic pathspec.
1412pub fn resolve_magic_pathspec(spec: &str, cwd_prefix: &str) -> Option<String> {
1413    if !spec.starts_with(":(") {
1414        return None;
1415    }
1416    let close_idx = spec.find(')')?;
1417    let magic_prefix = &spec[..=close_idx];
1418    let tail = &spec[close_idx + 1..];
1419    Some(resolve_magic_pathspec_parts(magic_prefix, tail, cwd_prefix))
1420}
1421
1422fn resolve_magic_pathspec_parts(magic_prefix: &str, tail: &str, cwd_prefix: &str) -> String {
1423    if has_magic_prefix_token(magic_prefix) {
1424        return format!("{magic_prefix}{tail}");
1425    }
1426
1427    if let Some(rooted_tail) = tail.strip_prefix('/') {
1428        return format!("{magic_prefix}{}", normalize_relative_path_str(rooted_tail));
1429    }
1430
1431    let combined = if cwd_prefix.is_empty() {
1432        normalize_relative_path_str(tail)
1433    } else {
1434        normalize_relative_path_str(&format!("{cwd_prefix}{tail}"))
1435    };
1436
1437    let cwd_base = normalize_relative_path_str(cwd_prefix.trim_end_matches('/'));
1438    if !cwd_base.is_empty()
1439        && (combined == cwd_base || combined.starts_with(&format!("{cwd_base}/")))
1440    {
1441        let after_base = combined
1442            .strip_prefix(&cwd_base)
1443            .unwrap_or(combined.as_str());
1444        let remainder = after_base.strip_prefix('/').unwrap_or(after_base);
1445        let magic_with_prefix = inject_magic_prefix_token(magic_prefix, &format!("{cwd_base}/"));
1446        return format!("{magic_with_prefix}{remainder}");
1447    }
1448
1449    format!("{magic_prefix}{combined}")
1450}
1451
1452fn has_magic_prefix_token(magic_prefix: &str) -> bool {
1453    let Some(inner) = magic_prefix
1454        .strip_prefix(":(")
1455        .and_then(|s| s.strip_suffix(')'))
1456    else {
1457        return false;
1458    };
1459    inner
1460        .split(',')
1461        .map(str::trim)
1462        .any(|token| token.starts_with("prefix:"))
1463}
1464
1465fn inject_magic_prefix_token(magic_prefix: &str, prefix: &str) -> String {
1466    let Some(inner) = magic_prefix
1467        .strip_prefix(":(")
1468        .and_then(|s| s.strip_suffix(')'))
1469    else {
1470        return magic_prefix.to_string();
1471    };
1472    if inner.trim().is_empty() {
1473        format!(":(prefix:{prefix})")
1474    } else {
1475        format!(":({inner},prefix:{prefix})")
1476    }
1477}
1478
1479fn normalize_relative_path_str(path: &str) -> String {
1480    let mut parts: Vec<String> = Vec::new();
1481    for component in std::path::Path::new(path).components() {
1482        match component {
1483            std::path::Component::CurDir => {}
1484            std::path::Component::ParentDir => {
1485                parts.pop();
1486            }
1487            std::path::Component::Normal(seg) => {
1488                parts.push(seg.to_string_lossy().to_string());
1489            }
1490            std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
1491        }
1492    }
1493    parts.join("/")
1494}
1495
1496/// Current directory relative to `work_tree`, or `None` if cwd is the work tree root.
1497#[must_use]
1498pub fn pathdiff(cwd: &Path, work_tree: &Path) -> Option<String> {
1499    let cwd_canon = cwd.canonicalize().ok()?;
1500    let wt_canon = work_tree.canonicalize().ok()?;
1501
1502    if cwd_canon == wt_canon {
1503        return None;
1504    }
1505
1506    cwd_canon
1507        .strip_prefix(&wt_canon)
1508        .ok()
1509        .map(|p| p.to_string_lossy().to_string())
1510}
1511
1512/// For exclude (and other cwd-relative) pathspec magic from a subdirectory, Git resolves the
1513/// pattern against the current directory (`:!sub/` from `repo/sub` → exclude `sub/sub/`).
1514fn prepend_cwd_to_short_exclude_pathspec(spec: &str, cwd: &str) -> Option<String> {
1515    let cwd = cwd.trim_end_matches('/');
1516    if cwd.is_empty() {
1517        return None;
1518    }
1519    let bytes = spec.as_bytes();
1520    if bytes.first().copied() != Some(b':') {
1521        return None;
1522    }
1523    // `:/path` is `:(top)` short form — exclude is relative to repo root, not cwd (t6132).
1524    if bytes.get(1).copied() == Some(b'/') {
1525        return None;
1526    }
1527    let mut i = 1usize;
1528    while i < bytes.len() && bytes[i] != b':' {
1529        let ch = bytes[i];
1530        if ch == b'^' {
1531            i += 1;
1532            continue;
1533        }
1534        let is_magic = matches!(ch, b'!' | b'/');
1535        if is_magic {
1536            i += 1;
1537            continue;
1538        }
1539        break;
1540    }
1541    if i < bytes.len() && bytes[i] == b':' {
1542        i += 1;
1543    }
1544    let pattern = spec.get(i..)?;
1545    if pattern.is_empty() || pattern.starts_with('/') {
1546        return None;
1547    }
1548    Some(format!("{}{}/{pattern}", &spec[..i], cwd))
1549}
1550
1551/// Resolve a pathspec string to a path relative to the repository work tree.
1552///
1553/// `prefix` is the current directory relative to the work tree (no trailing slash),
1554/// or `None` when cwd is the work tree root.
1555#[must_use]
1556pub fn resolve_pathspec(pathspec: &str, work_tree: &Path, prefix: Option<&str>) -> String {
1557    // Git: `.` at repo root means "match the whole tree" (not an empty pathspec).
1558    // An empty resolved pathspec would match nothing and breaks `grep -- . t` max-depth.
1559    if pathspec == "." {
1560        return match prefix {
1561            Some(p) if !p.is_empty() => p.to_owned(),
1562            _ => ".".to_owned(),
1563        };
1564    }
1565    if pathspec.contains("../") || pathspec.starts_with("../") {
1566        let cwd = std::env::current_dir().unwrap_or_default();
1567        let abs = cwd.join(pathspec);
1568        let wt_canon = work_tree
1569            .canonicalize()
1570            .unwrap_or_else(|_| work_tree.to_path_buf());
1571        let mut parts: Vec<std::ffi::OsString> = Vec::new();
1572        for component in abs.components() {
1573            use std::path::Component;
1574            match component {
1575                Component::ParentDir => {
1576                    parts.pop();
1577                }
1578                Component::CurDir => {}
1579                other => parts.push(other.as_os_str().to_os_string()),
1580            }
1581        }
1582        let abs_norm: PathBuf = parts.iter().collect();
1583        if let Ok(rel) = abs_norm.strip_prefix(&wt_canon) {
1584            return rel.to_string_lossy().to_string();
1585        }
1586    }
1587    if Path::new(pathspec).is_absolute() {
1588        let abs = Path::new(pathspec);
1589        let wt_canon = work_tree
1590            .canonicalize()
1591            .unwrap_or_else(|_| work_tree.to_path_buf());
1592        let abs_canon = abs.canonicalize().unwrap_or_else(|_| abs.to_path_buf());
1593        if let Ok(rel) = abs_canon.strip_prefix(&wt_canon) {
1594            return rel.to_string_lossy().to_string();
1595        }
1596        return pathspec.to_owned();
1597    }
1598
1599    if pathspec.starts_with(':') {
1600        if let Some(p) = prefix {
1601            if !p.is_empty() && !literal_pathspecs_enabled() {
1602                let cwd_ps = format!("{}/", p.trim_end_matches('/'));
1603                if pathspec.starts_with(":(") {
1604                    if let Some(resolved) = resolve_magic_pathspec(pathspec, &cwd_ps) {
1605                        return resolved;
1606                    }
1607                    return pathspec.to_owned();
1608                }
1609                if pathspec_is_exclude(pathspec) {
1610                    if let Some(fixed) = prepend_cwd_to_short_exclude_pathspec(pathspec, p) {
1611                        return fixed;
1612                    }
1613                }
1614            }
1615        }
1616        if let Some(rest) = pathspec.strip_prefix(":/") {
1617            // `:/!foo` / `:/^bar` — `:/` is `:(top)`; the tail is still short magic, not a literal path.
1618            if rest.starts_with('!') || rest.starts_with('^') {
1619                return pathspec.to_owned();
1620            }
1621            return rest.to_owned();
1622        }
1623        // Long magic `:(...)` must stay intact — `:(exclude)path` is not the same as `path`
1624        // (t6132-pathspec-exclude, grep --untracked with exclude pathspecs).
1625        if pathspec.starts_with(":(") {
1626            return pathspec.to_owned();
1627        }
1628        return pathspec.to_owned();
1629    }
1630
1631    match prefix {
1632        Some(p) if !p.is_empty() => {
1633            normalize_relative_path_str(&PathBuf::from(p).join(pathspec).to_string_lossy())
1634        }
1635        _ => pathspec.to_owned(),
1636    }
1637}
1638
1639/// Resolve a pathspec and ensure it lies inside `work_tree` (used by `git add`, etc.).
1640///
1641/// Returns [`PathOutsideRepository`] when resolution stays absolute, matching Git's
1642/// `'%s' is outside repository at '%s'` fatal (t7010).
1643pub fn resolve_pathspec_in_worktree(
1644    elt: &str,
1645    pathspec: &str,
1646    work_tree: &Path,
1647    prefix: Option<&str>,
1648) -> Result<String, PathOutsideRepository> {
1649    let resolved = resolve_pathspec(pathspec, work_tree, prefix);
1650    if Path::new(&resolved).is_absolute() {
1651        let wt = work_tree
1652            .canonicalize()
1653            .unwrap_or_else(|_| work_tree.to_path_buf());
1654        return Err(PathOutsideRepository {
1655            elt: elt.to_string(),
1656            path: resolved,
1657            work_tree: wt,
1658        });
1659    }
1660    Ok(resolved)
1661}
1662
1663/// Normalize a worktree file path for porcelain commands (`blame`, `log`, …).
1664///
1665/// Accepts repo-relative or absolute paths under `work_tree`.
1666#[must_use]
1667pub fn normalize_worktree_file_path(
1668    file_path: &str,
1669    work_tree: &Path,
1670    prefix: Option<&str>,
1671) -> String {
1672    let resolved = resolve_pathspec(file_path, work_tree, prefix);
1673    if Path::new(&resolved).is_absolute() {
1674        file_path.to_string()
1675    } else {
1676        resolved
1677    }
1678}
1679
1680#[cfg(test)]
1681mod tree_entry_pathspec_tests {
1682    use super::*;
1683
1684    #[test]
1685    fn t6130_bracket_filename_matches_pathspec() {
1686        assert!(matches_pathspec("f[o][o]", "f[o][o]"));
1687        assert!(matches_pathspec(":(glob)f[o][o]", "f[o][o]"));
1688    }
1689
1690    #[test]
1691    fn literal_prefix_and_exact() {
1692        assert!(matches_pathspec("path1", "path1/file1"));
1693        assert!(matches_pathspec_with_context(
1694            "path1/",
1695            "path1/file1",
1696            PathspecMatchContext::default()
1697        ));
1698        assert!(matches_pathspec("file0", "file0"));
1699        assert!(!matches_pathspec("path", "path1/file1"));
1700    }
1701
1702    #[test]
1703    fn ls_tree_bracket_in_name_is_literal_prefix() {
1704        assert!(matches_ls_tree_pathspec(
1705            "a[a]",
1706            "a[a]/three",
1707            0o100644,
1708            &[]
1709        ));
1710        assert!(!matches_pathspec_with_context(
1711            "a[a]",
1712            "a[a]/three",
1713            PathspecMatchContext::default()
1714        ));
1715    }
1716
1717    #[test]
1718    fn wildcards_cross_slash_by_default() {
1719        assert!(matches_pathspec("f*", "file0"));
1720        assert!(matches_pathspec("*file1", "path1/file1"));
1721        assert!(matches_pathspec_with_context(
1722            "path1/f*",
1723            "path1",
1724            PathspecMatchContext {
1725                is_directory: true,
1726                ..Default::default()
1727            }
1728        ));
1729        assert!(matches_pathspec("path1/*file1", "path1/file1"));
1730    }
1731
1732    #[test]
1733    fn glob_double_star_txt_at_repo_root() {
1734        assert!(pathspec_matches(":(glob)**/*.txt", "untracked.txt"));
1735        assert!(pathspec_matches(":(glob)**/*.txt", "d/untracked.txt"));
1736    }
1737
1738    #[test]
1739    fn trailing_slash_directory_only() {
1740        assert!(!matches_pathspec_with_context(
1741            "file0/",
1742            "file0",
1743            PathspecMatchContext::default()
1744        ));
1745        assert!(matches_pathspec_with_context(
1746            "file0/",
1747            "file0",
1748            PathspecMatchContext {
1749                is_directory: true,
1750                ..Default::default()
1751            }
1752        ));
1753        assert!(matches_pathspec_with_context(
1754            "submod/",
1755            "submod",
1756            PathspecMatchContext {
1757                is_git_submodule: true,
1758                ..Default::default()
1759            }
1760        ));
1761    }
1762
1763    #[test]
1764    fn exclude_top_short_magic_subtracts_from_positive() {
1765        let specs = vec!["*".to_string(), ":/!sub2".to_string()];
1766        assert!(matches_pathspec_list("sub/file", &specs));
1767        assert!(!matches_pathspec_list("sub2/file", &specs));
1768        assert!(pathspec_exclude_matches(":/!sub2", "sub2/file"));
1769    }
1770}
1771
1772#[cfg(test)]
1773mod pathspec_list_tests {
1774    use super::*;
1775    use crate::crlf::parse_gitattributes_content;
1776
1777    #[test]
1778    fn exclude_removes_paths_matching_icase_positive() {
1779        let specs = vec![
1780            ":(icase)*.txt".to_string(),
1781            ":(exclude)submodule/subsub/*".to_string(),
1782        ];
1783        assert!(path_allowed_by_pathspec_list(&specs, "submodule/g.txt"));
1784        assert!(!path_allowed_by_pathspec_list(
1785            &specs,
1786            "submodule/subsub/e.txt"
1787        ));
1788    }
1789
1790    #[test]
1791    fn prefixed_attr_exclude_removes_matching_child_path() {
1792        let specs = vec![
1793            "sub".to_string(),
1794            ":(exclude,attr:labelB,prefix:sub/)".to_string(),
1795        ];
1796        let exclude_only = vec![":(exclude,attr:labelB,prefix:sub/)".to_string()];
1797        let attrs = parse_gitattributes_content("fileB labelB\n");
1798        assert!(!matches_pathspec_list_for_object(
1799            "sub/fileB",
1800            0o100644,
1801            &attrs,
1802            &specs,
1803        ));
1804        assert!(!matches_pathspec_list_for_object(
1805            "sub/fileB",
1806            0o100644,
1807            &attrs,
1808            &exclude_only,
1809        ));
1810    }
1811}