Skip to main content

grit_lib/
pathspec.rs

1//! Git-compatible pathspec matching (magic tokens and global flags).
2//!
3//! ```rust
4//! use grit_lib::worktree::pathspec::parse_pathspecs_from_source;
5//!
6//! let specs = parse_pathspecs_from_source(b"README.md\n", false).expect("parse");
7//! assert_eq!(specs, ["README.md"]);
8//! ```
9//!
10//! Global flags are read from the same environment variables as Git:
11//! `GIT_LITERAL_PATHSPECS`, `GIT_GLOB_PATHSPECS`, `GIT_NOGLOB_PATHSPECS`,
12//! `GIT_ICASE_PATHSPECS`. The `grit` binary sets these from CLI flags such as
13//! `--literal-pathspecs` before dispatching subcommands.
14
15use crate::git_path::CanonicalizeExt;
16use std::borrow::Cow;
17use std::path::{Path, PathBuf};
18
19use hashbrown::HashSet;
20
21use crate::attributes::MacroTable;
22use crate::crlf::path_gitattribute_values;
23use crate::crlf::AttrRule;
24pub use crate::error::{AttrPathspecError, PathspecFromFileParseError, PathspecResolveError};
25use crate::precompose_config::argv_precompose_enabled;
26use crate::repo::Repository;
27use crate::unicode_normalization::precompose_utf8_path;
28use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
29
30/// Returns the length of the leading literal segment before the first glob metacharacter,
31/// matching Git's `simple_length()` (`*` `?` `[` `\`) on bytes.
32#[must_use]
33pub fn simple_length(match_str: &str) -> usize {
34    let b = match_str.as_bytes();
35    let mut len = 0usize;
36    for &c in b {
37        if matches!(c, b'*' | b'?' | b'[' | b'\\') {
38            break;
39        }
40        len += 1;
41    }
42    len
43}
44
45/// Whether the pattern uses wildcards after Git pathspec escaping rules.
46#[must_use]
47pub fn has_glob_chars(s: &str) -> bool {
48    simple_length(s) < s.len()
49}
50
51/// Read pathspec entries from raw file bytes (stdin or file), matching Git's
52/// `--pathspec-from-file` / `--pathspec-file-nul` rules.
53///
54/// * **NUL mode:** entries are separated by `NUL`; each segment is taken literally
55///   (no trimming or C-style unquoting).
56/// * **Line mode:** entries are separated by `LF`; optional `CR` before `LF`
57///   is stripped; optional trailing line without a final newline is included;
58///   double-quoted lines are C-unquoted (including octal escapes).
59pub fn parse_pathspecs_from_source(
60    data: &[u8],
61    nul_terminated: bool,
62) -> Result<Vec<String>, PathspecFromFileParseError> {
63    if nul_terminated {
64        let mut chunks: Vec<&[u8]> = data.split(|b| *b == 0).collect();
65        if chunks.last().copied() == Some(&[] as &[u8]) {
66            chunks.pop();
67        }
68        let mut out = Vec::with_capacity(chunks.len());
69        for chunk in chunks {
70            if chunk.is_empty() {
71                out.push(String::new());
72                continue;
73            }
74            let entry = std::str::from_utf8(chunk)
75                .map_err(|_| PathspecFromFileParseError::InvalidUtf8InNulRecord)?;
76            out.push(entry.to_string());
77        }
78        return Ok(out);
79    }
80
81    let text = String::from_utf8_lossy(data);
82    let mut out = Vec::new();
83    for raw in text.split_inclusive('\n') {
84        let line = raw.trim_end_matches('\n').trim_end_matches('\r');
85        if line.is_empty() {
86            out.push(String::new());
87            continue;
88        }
89        if line.starts_with('"') {
90            if line.ends_with('"') && line.len() >= 2 {
91                out.push(unquote_c_style_pathspec_line(line)?);
92            } else {
93                return Err(PathspecFromFileParseError::InvalidCStyleQuoting {
94                    line: line.to_string(),
95                });
96            }
97        } else {
98            out.push(line.to_string());
99        }
100    }
101    Ok(out)
102}
103
104/// Unquote a single `--pathspec-from-file` line that is wrapped in double quotes.
105fn unquote_c_style_pathspec_line(s: &str) -> Result<String, PathspecFromFileParseError> {
106    let bytes = s.as_bytes();
107    if bytes.first() != Some(&b'"') || bytes.last() != Some(&b'"') || bytes.len() < 2 {
108        return Err(PathspecFromFileParseError::InvalidCStyleQuoting {
109            line: s.to_string(),
110        });
111    }
112
113    let inner = &bytes[1..bytes.len() - 1];
114    let mut out = Vec::with_capacity(inner.len());
115    let mut i = 0;
116    while i < inner.len() {
117        if inner[i] != b'\\' {
118            out.push(inner[i]);
119            i += 1;
120            continue;
121        }
122        i += 1;
123        if i >= inner.len() {
124            return Err(PathspecFromFileParseError::InvalidEscapeAtEnd);
125        }
126        match inner[i] {
127            b'\\' => out.push(b'\\'),
128            b'"' => out.push(b'"'),
129            b'a' => out.push(7),
130            b'b' => out.push(8),
131            b'f' => out.push(12),
132            b'n' => out.push(b'\n'),
133            b'r' => out.push(b'\r'),
134            b't' => out.push(b'\t'),
135            b'v' => out.push(11),
136            c if c.is_ascii_digit() => {
137                if i + 2 >= inner.len() {
138                    return Err(PathspecFromFileParseError::TruncatedOctalEscape);
139                }
140                let oct = std::str::from_utf8(&inner[i..i + 3])
141                    .map_err(|_| PathspecFromFileParseError::InvalidOctalBytes)?;
142                out.push(
143                    u8::from_str_radix(oct, 8)
144                        .map_err(|_| PathspecFromFileParseError::InvalidOctalValue)?,
145                );
146                i += 2;
147            }
148            other => {
149                return Err(PathspecFromFileParseError::InvalidEscapeSequence {
150                    escape: char::from(other),
151                });
152            }
153        }
154        i += 1;
155    }
156    String::from_utf8(out).map_err(|_| PathspecFromFileParseError::InvalidUtf8InQuotedLine)
157}
158
159/// Raw bytes for [`ResolvedPathspecs::resolve`] from `--pathspec-from-file` (read by the caller).
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct PathspecInput {
162    /// File or stdin contents.
163    pub data: Vec<u8>,
164    /// When true, entries are NUL-separated (`--pathspec-file-nul`).
165    pub nul_terminated: bool,
166}
167
168impl PathspecInput {
169    /// Build input from bytes already read from a file or stdin.
170    #[must_use]
171    pub fn new(data: Vec<u8>, nul_terminated: bool) -> Self {
172        Self {
173            data,
174            nul_terminated,
175        }
176    }
177}
178
179/// Pathspec argv fragments resolved against a repository work tree.
180#[derive(Debug, Clone, Default, PartialEq, Eq)]
181pub struct ResolvedPathspecs {
182    /// Work-tree-relative pathspec strings for matching.
183    pub specs: Vec<String>,
184    /// User-facing spellings aligned with `specs` (argv or file lines).
185    pub sources: Vec<String>,
186    /// True when the caller supplied argv paths and/or a from-file (not "whole tree").
187    pub scoped: bool,
188}
189
190impl ResolvedPathspecs {
191    /// Resolve `raw` argv pathspecs and optional `--pathspec-from-file` bytes against `repo`.
192    ///
193    /// `cwd` is the process working directory used for relative pathspec prefixes (never read
194    /// from the environment inside the library).
195    ///
196    /// # Examples
197    ///
198    /// ```
199    /// use grit_lib::worktree::pathspec::{PathspecInput, ResolvedPathspecs};
200    /// use grit_lib::repo::{InitOptions, Repository};
201    /// use std::fs;
202    ///
203    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
204    /// let dir = tempfile::tempdir()?;
205    /// let repo = Repository::init(dir.path(), &InitOptions::default())?;
206    /// fs::write(dir.path().join("a.txt"), b"x")?;
207    /// let resolved = ResolvedPathspecs::resolve(
208    ///     &repo,
209    ///     dir.path(),
210    ///     &["a.txt".to_string()],
211    ///     None,
212    /// )?;
213    /// assert_eq!(resolved.specs, ["a.txt"]);
214    /// let from_file = PathspecInput::new(b"a.txt\n".to_vec(), false);
215    /// let from_file = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(from_file))?;
216    /// assert_eq!(from_file.specs, ["a.txt"]);
217    /// # Ok(())
218    /// # }
219    /// ```
220    pub fn resolve(
221        repo: &Repository,
222        cwd: &Path,
223        raw: &[String],
224        from_file: Option<PathspecInput>,
225    ) -> Result<Self, PathspecResolveError> {
226        let work_tree = repo
227            .work_tree()
228            .ok_or(PathspecResolveError::NeedsWorkTree)?;
229        let prefix = pathdiff(cwd, work_tree);
230        let scoped = from_file.is_some() || !raw.is_empty();
231
232        let mut sources = Vec::new();
233        if let Some(input) = from_file {
234            let parsed = parse_pathspecs_from_source(&input.data, input.nul_terminated)?;
235            sources.extend(parsed);
236        }
237        for sel in raw {
238            sources.push(sel.clone());
239        }
240
241        if scoped && sources.is_empty() {
242            return Err(PathspecResolveError::EmptyPathspec);
243        }
244        for sel in &sources {
245            if sel.is_empty() {
246                return Err(PathspecResolveError::EmptyPathspec);
247            }
248        }
249
250        let argv_precompose = repo.pathspec_precompose_enabled();
251        let specs = sources
252            .iter()
253            .map(|sel| {
254                resolve_pathspec_in_worktree_with_argv_precompose(
255                    sel,
256                    sel,
257                    work_tree,
258                    prefix.as_deref(),
259                    argv_precompose,
260                )
261            })
262            .collect::<Result<Vec<_>, _>>()?;
263
264        Ok(Self {
265            specs,
266            sources,
267            scoped,
268        })
269    }
270
271    /// Whether the invocation targets the entire work tree (no path arguments or from-file).
272    #[must_use]
273    pub fn is_entire_tree(&self) -> bool {
274        !self.scoped
275    }
276
277    /// Consume `self` and return resolved specs and user-facing sources.
278    ///
279    /// When [`Self::is_entire_tree`] is true, both vectors are empty (match legacy
280    /// "stage/remove/clean the whole tree" call sites).
281    #[must_use]
282    pub fn into_pathspec_lists(self) -> (Vec<String>, Vec<String>) {
283        if self.scoped {
284            (self.specs, self.sources)
285        } else {
286            (Vec::new(), Vec::new())
287        }
288    }
289
290    /// Resolved pathspec strings only; empty when the invocation is unscoped.
291    #[must_use]
292    pub fn into_specs(self) -> Vec<String> {
293        if self.scoped {
294            self.specs
295        } else {
296            Vec::new()
297        }
298    }
299}
300
301#[derive(Debug, Clone, Default)]
302struct PathspecMagic {
303    literal: bool,
304    glob: bool,
305    icase: bool,
306    exclude: bool,
307    /// `:(top)` / short `:/` — paths are relative to repo root.
308    top: bool,
309    prefix: Option<String>,
310    /// `:(attr:...)` requirements.
311    attr_requirements: Vec<AttrRequirement>,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
315enum AttrRequirement {
316    Set(String),
317    Unset(String),
318    Unspecified(String),
319    Value(String, String),
320}
321
322impl AttrRequirement {
323    fn name(&self) -> &str {
324        match self {
325            AttrRequirement::Set(name)
326            | AttrRequirement::Unset(name)
327            | AttrRequirement::Unspecified(name)
328            | AttrRequirement::Value(name, _) => name,
329        }
330    }
331}
332
333/// The library never reads the process environment, so Git's `GIT_*_PATHSPECS` globals are
334/// always unset here and every global flag takes its default. This runs on every pathspec
335/// comparison, so it must stay allocation-free.
336fn git_env_bool(_key: &str, default: bool) -> bool {
337    default
338}
339
340fn literal_global() -> bool {
341    git_env_bool("GIT_LITERAL_PATHSPECS", false)
342}
343
344/// Whether `GIT_LITERAL_PATHSPECS` is enabled (shell `*` and `?` are literal, not globs).
345#[must_use]
346pub fn literal_pathspecs_enabled() -> bool {
347    literal_global()
348}
349
350fn glob_global() -> bool {
351    git_env_bool("GIT_GLOB_PATHSPECS", false)
352}
353
354fn noglob_global() -> bool {
355    git_env_bool("GIT_NOGLOB_PATHSPECS", false)
356}
357
358fn icase_global() -> bool {
359    git_env_bool("GIT_ICASE_PATHSPECS", false)
360}
361
362/// Validates global pathspec environment flags the same way Git does.
363///
364/// Returns an error message suitable for `bail!` when flags are incompatible.
365pub fn validate_global_pathspec_flags() -> Result<(), String> {
366    let lit = literal_global();
367    let glob = glob_global();
368    let noglob = noglob_global();
369    let icase = icase_global();
370
371    if glob && noglob {
372        return Err("global 'glob' and 'noglob' pathspec settings are incompatible".to_string());
373    }
374    if lit && (glob || noglob || icase) {
375        return Err(
376            "global 'literal' pathspec setting is incompatible with all other global pathspec settings"
377                .to_string(),
378        );
379    }
380    Ok(())
381}
382
383fn is_valid_attr_name(name: &str) -> bool {
384    !name.is_empty()
385        && name
386            .bytes()
387            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
388}
389
390fn split_attr_expr(expr: &str) -> Result<Vec<String>, AttrPathspecError> {
391    let mut parts = Vec::new();
392    let mut cur = String::new();
393    let mut in_value = false;
394    let mut escaped = false;
395
396    for ch in expr.chars() {
397        if escaped {
398            if ch.is_ascii_whitespace() {
399                return Err(AttrPathspecError::DanglingEscape);
400            }
401            if ch != ',' {
402                return Err(AttrPathspecError::UnsupportedEscape);
403            }
404            cur.push(ch);
405            escaped = false;
406            continue;
407        }
408        if in_value && ch == '\\' {
409            escaped = true;
410            continue;
411        }
412        if ch == '=' {
413            in_value = true;
414            cur.push(ch);
415            continue;
416        }
417        if ch.is_ascii_whitespace() {
418            if !cur.is_empty() {
419                parts.push(cur);
420                cur = String::new();
421            }
422            in_value = false;
423            continue;
424        }
425        cur.push(ch);
426    }
427
428    if escaped {
429        return Err(AttrPathspecError::DanglingEscape);
430    }
431    if !cur.is_empty() {
432        parts.push(cur);
433    }
434    Ok(parts)
435}
436
437fn parse_attr_requirements(expr: &str) -> Result<Vec<AttrRequirement>, AttrPathspecError> {
438    let name_only = |name: &str| {
439        if name.contains('=') || !is_valid_attr_name(name) {
440            Err(AttrPathspecError::InvalidName(name.to_owned()))
441        } else {
442            Ok(name.to_owned())
443        }
444    };
445    let mut out = Vec::new();
446    for token in split_attr_expr(expr)? {
447        let req = if let Some(name) = token.strip_prefix('-') {
448            AttrRequirement::Unset(name_only(name)?)
449        } else if let Some(name) = token.strip_prefix('!') {
450            AttrRequirement::Unspecified(name_only(name)?)
451        } else if let Some((name, value)) = token.split_once('=') {
452            AttrRequirement::Value(name_only(name)?, value.to_owned())
453        } else {
454            AttrRequirement::Set(name_only(&token)?)
455        };
456        out.push(req);
457    }
458    if out.is_empty() {
459        return Err(AttrPathspecError::Empty);
460    }
461    Ok(out)
462}
463
464/// The `attr:` clauses inside a long-form `:(...)` magic prefix (none for other specs).
465fn attr_clauses(spec: &str) -> Vec<String> {
466    if literal_global() {
467        return Vec::new();
468    }
469    let Some(rest) = spec.strip_prefix(":(") else {
470        return Vec::new();
471    };
472    let Some(close) = rest.find(')') else {
473        return Vec::new();
474    };
475    split_long_magic_tokens_raw(&rest[..close])
476        .into_iter()
477        .filter_map(|token| token.trim_start().strip_prefix("attr:").map(str::to_owned))
478        .collect()
479}
480
481/// Validate the `:(attr:...)` magic of every spec in `specs`.
482///
483/// Specs without attr magic are always accepted. A spec is rejected when it has more than one
484/// `attr:` clause or a clause that does not parse (empty clause, invalid attribute name, bad
485/// escape). A clause of only whitespace has no requirements and is accepted.
486///
487/// # Errors
488///
489/// [`crate::error::Error::InvalidAttrPathspec`] naming the first rejected spec and its
490/// [`AttrPathspecError`].
491pub fn validate_attr_pathspecs(specs: &[String]) -> crate::error::Result<()> {
492    for spec in specs {
493        let invalid = |reason| crate::error::Error::InvalidAttrPathspec {
494            spec: spec.clone(),
495            reason,
496        };
497        let clauses = attr_clauses(spec);
498        if clauses.len() > 1 {
499            return Err(invalid(AttrPathspecError::MultipleAttrClauses));
500        }
501        match clauses.first() {
502            Some(expr) if expr.is_empty() => return Err(invalid(AttrPathspecError::Empty)),
503            // Whitespace alone is an attr clause with no requirements, which Git accepts.
504            Some(expr) if expr.trim().is_empty() => {}
505            Some(expr) => {
506                parse_attr_requirements(expr).map_err(invalid)?;
507            }
508            None => {}
509        }
510    }
511    Ok(())
512}
513
514fn split_long_magic_tokens(magic_part: &str) -> Vec<String> {
515    split_long_magic_tokens_raw(magic_part)
516        .into_iter()
517        .map(|token| token.trim().to_string())
518        .collect()
519}
520
521/// Split long magic on unescaped `,`, keeping surrounding whitespace.
522fn split_long_magic_tokens_raw(magic_part: &str) -> Vec<String> {
523    let mut tokens = Vec::new();
524    let mut cur = String::new();
525    let mut escaped = false;
526    for ch in magic_part.chars() {
527        if escaped {
528            cur.push('\\');
529            cur.push(ch);
530            escaped = false;
531            continue;
532        }
533        if ch == '\\' {
534            escaped = true;
535            continue;
536        }
537        if ch == ',' {
538            tokens.push(std::mem::take(&mut cur));
539            continue;
540        }
541        cur.push(ch);
542    }
543    if escaped {
544        cur.push('\\');
545    }
546    tokens.push(cur);
547    tokens
548}
549
550fn parse_long_magic(rest_after_paren: &str) -> Option<(PathspecMagic, &str)> {
551    let close = rest_after_paren.find(')')?;
552    let magic_part = &rest_after_paren[..close];
553    let tail = &rest_after_paren[close + 1..];
554    let mut magic = PathspecMagic::default();
555    for raw in split_long_magic_tokens(magic_part) {
556        let token = raw.trim();
557        if token.is_empty() {
558            continue;
559        }
560        if let Some(p) = token.strip_prefix("prefix:") {
561            magic.prefix = Some(p.to_string());
562            continue;
563        }
564        if let Some(expr) = token.strip_prefix("attr:") {
565            if let Ok(reqs) = parse_attr_requirements(expr) {
566                magic.attr_requirements = reqs;
567            }
568            continue;
569        }
570        if token.eq_ignore_ascii_case("literal") {
571            magic.literal = true;
572        } else if token.eq_ignore_ascii_case("glob") {
573            magic.glob = true;
574        } else if token.eq_ignore_ascii_case("icase") {
575            magic.icase = true;
576        } else if token.eq_ignore_ascii_case("exclude") {
577            magic.exclude = true;
578        } else if token.eq_ignore_ascii_case("top") {
579            magic.top = true;
580        }
581    }
582    Some((magic, tail))
583}
584
585/// `elem` is the full pathspec beginning with `:` (short magic form, not `:(...)`).
586fn parse_short_magic(elem: &str) -> (PathspecMagic, &str) {
587    let bytes = elem.as_bytes();
588    let mut i = 1usize;
589    let mut magic = PathspecMagic::default();
590    while i < bytes.len() && bytes[i] != b':' {
591        let ch = bytes[i];
592        if ch == b'^' {
593            magic.exclude = true;
594            i += 1;
595            continue;
596        }
597        let is_magic = match ch {
598            b'!' => {
599                magic.exclude = true;
600                true
601            }
602            b'/' => {
603                magic.top = true;
604                true
605            } // short `:/` = top
606            _ => false,
607        };
608        if is_magic {
609            i += 1;
610            continue;
611        }
612        break;
613    }
614    if i < bytes.len() && bytes[i] == b':' {
615        i += 1;
616    }
617    (magic, &elem[i..])
618}
619
620/// Strip `:(magic)` / `:magic` prefix when not in literal-global mode.
621fn parse_element_magic(elem: &str) -> (PathspecMagic, &str) {
622    if !elem.starts_with(':') || literal_global() {
623        return (PathspecMagic::default(), elem);
624    }
625    if let Some(rest) = elem.strip_prefix(":(") {
626        return parse_long_magic(rest).unwrap_or((PathspecMagic::default(), elem));
627    }
628    parse_short_magic(elem)
629}
630
631fn combine_magic(element: PathspecMagic) -> PathspecMagic {
632    let mut m = element;
633    if literal_global() {
634        m.literal = true;
635    }
636    if glob_global() && !m.literal {
637        m.glob = true;
638    }
639    if icase_global() {
640        m.icase = true;
641    }
642    if noglob_global() && !m.glob {
643        m.literal = true;
644    }
645    m
646}
647
648fn strip_top_magic(mut pattern: &str) -> &str {
649    if let Some(r) = pattern.strip_prefix(":/") {
650        pattern = r;
651    }
652    pattern
653}
654
655/// Path prefix used for Bloom-filter lookups (`revision.c` `convert_pathspec_to_bloom_keyvec`).
656///
657/// `cwd_from_repo_root` is the path from the repository work tree to the process cwd, using `/`
658/// separators and no leading slash (empty string at repo root). Used for `:(top)` / `:/`.
659#[must_use]
660pub fn bloom_lookup_prefix_with_cwd(
661    spec: &str,
662    cwd_from_repo_root: Option<&str>,
663) -> Option<String> {
664    let (elem_magic, raw_pattern) = parse_element_magic(spec);
665    let magic = combine_magic(elem_magic);
666    if magic.exclude || magic.icase {
667        return None;
668    }
669    let pattern = strip_top_magic(raw_pattern);
670    if pattern.is_empty() {
671        return None;
672    }
673    let combined = if magic.top {
674        let cwd = cwd_from_repo_root.unwrap_or("").trim_end_matches('/');
675        if cwd.is_empty() {
676            pattern.to_string()
677        } else {
678            format!("{cwd}/{pattern}")
679        }
680    } else if let Some(prefix) = magic.prefix.as_deref() {
681        let base = prefix.trim_end_matches('/');
682        if base.is_empty() {
683            pattern.to_string()
684        } else {
685            normalize_relative_path_str(&format!("{base}/{pattern}"))
686        }
687    } else {
688        pattern.to_string()
689    };
690    let pattern = combined.as_str();
691    let mut len = simple_length(pattern);
692    if len != pattern.len() {
693        while len > 0 && pattern.as_bytes()[len - 1] != b'/' {
694            len -= 1;
695        }
696    }
697    while len > 0 && pattern.as_bytes()[len - 1] == b'/' {
698        len -= 1;
699    }
700    if len == 0 {
701        return None;
702    }
703    Some(combined[..len].to_string())
704}
705
706/// Bloom filter lookup prefix for a pathspec at the repository root.
707#[must_use]
708pub fn bloom_lookup_prefix(spec: &str) -> Option<String> {
709    bloom_lookup_prefix_with_cwd(spec, None)
710}
711
712/// Whether every pathspec can participate in Bloom precomputation (Git `forbid_bloom_filters`).
713#[must_use]
714pub fn pathspecs_allow_bloom(specs: &[String]) -> bool {
715    specs.iter().all(|s| {
716        !s.is_empty() && !pathspec_is_exclude(s) && bloom_lookup_prefix_with_cwd(s, None).is_some()
717    })
718}
719
720/// Whether `path` is included when Git applies a pathspec list with optional `:(exclude)` entries.
721///
722/// A path is rejected if any exclude pathspec matches it. When at least one non-exclude pathspec is
723/// present, the path must also match one of those positives (`OR` semantics).
724#[must_use]
725pub fn path_allowed_by_pathspec_list(specs: &[String], path: &str) -> bool {
726    let mut has_positive = false;
727    let mut positive_match = false;
728    for s in specs {
729        let (elem, raw_pattern) = parse_element_magic(s);
730        let magic = combine_magic(elem);
731        if magic.exclude {
732            if path_matches_pathspec_tail(raw_pattern, path, magic) {
733                return false;
734            }
735            continue;
736        }
737        has_positive = true;
738        if pathspec_matches(s, path) {
739            positive_match = true;
740        }
741    }
742    !has_positive || positive_match
743}
744
745/// True when `spec` matches `path` for pathspec bookkeeping (positive match or exclude hit).
746#[must_use]
747pub fn pathspec_contributes_match(spec: &str, path: &str) -> bool {
748    pathspec_matches(spec, path) || pathspec_exclude_matches(spec, path)
749}
750
751/// Path under `prefix:` magic (e.g. `sub/`) for repository-relative `path`, including the
752/// directory entry `sub` when the prefix is `sub/`.
753fn path_for_match_under_magic_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
754    let base = prefix.trim_end_matches('/');
755    if base.is_empty() {
756        return Some(path);
757    }
758    if path == base {
759        return Some("");
760    }
761    if let Some(rest) = path.strip_prefix(prefix) {
762        return Some(rest);
763    }
764    let anchored = if prefix.ends_with('/') {
765        prefix.to_string()
766    } else {
767        format!("{prefix}/")
768    };
769    path.starts_with(&anchored).then(|| &path[anchored.len()..])
770}
771
772fn path_matches_pathspec_tail(raw_pattern: &str, path: &str, magic: PathspecMagic) -> bool {
773    if magic.literal && magic.glob {
774        return false;
775    }
776    let pattern = strip_top_magic(raw_pattern);
777    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
778        match path_for_match_under_magic_prefix(path, prefix) {
779            Some(rest) => rest,
780            None => return false,
781        }
782    } else {
783        path
784    };
785    pathspec_matches_tail(pattern, path_for_match, magic)
786}
787
788/// True if `path` is matched by `spec` (Git pathspec syntax, including magic and globals).
789///
790/// Same as [`matches_pathspec`] (default file context; exclude specs never match positively here).
791/// See [`matches_pathspec_list`].
792#[must_use]
793pub fn pathspec_matches(spec: &str, path: &str) -> bool {
794    matches_pathspec(spec, path)
795}
796
797/// Returns whether `spec` uses Git's exclude magic (`:(exclude)`, `:!`, `:^`, etc.).
798#[must_use]
799pub fn pathspec_is_exclude(spec: &str) -> bool {
800    let (elem_magic, _) = parse_element_magic(spec);
801    combine_magic(elem_magic).exclude
802}
803
804/// Whether a path-limited revision tree walk should recurse into directory `path`.
805///
806/// Uses [`pathspec_wants_descent_into_tree`] for non-exclude specs. Exclude-only lists never
807/// trigger that helper alone, but Git's implicit match-all still requires walking non-excluded
808/// directories so nested paths can match [`matches_pathspec_list`].
809#[must_use]
810pub(crate) fn pathspec_wants_descent_for_rev_tree(path: &str, specs: &[String]) -> bool {
811    if specs.is_empty() {
812        return true;
813    }
814    for spec in specs {
815        if !pathspec_is_exclude(spec) && pathspec_wants_descent_into_tree(spec, path) {
816            return true;
817        }
818    }
819    if !specs.iter().any(|s| pathspec_is_exclude(s)) {
820        return false;
821    }
822    let ctx = PathspecMatchContext {
823        is_directory: true,
824        is_git_submodule: false,
825        precompose_paths: false,
826    };
827    for spec in specs {
828        if pathspec_is_exclude(spec)
829            && pathspec_exclude_element_matches_with_context(spec, path, ctx)
830        {
831            return false;
832        }
833    }
834    !specs.iter().any(|s| !pathspec_is_exclude(s))
835}
836
837/// Whether a path-limited tree walk must look at entry `path`: it matches `specs`, or it is a
838/// directory that [`pathspec_wants_descent_for_rev_tree`] says may hold a match.
839pub(crate) fn pathspec_entry_is_relevant(path: &str, is_tree: bool, specs: &[String]) -> bool {
840    matches_pathspec_list(path, specs)
841        || (is_tree && pathspec_wants_descent_for_rev_tree(path, specs))
842}
843
844/// Whether a path-limited tree walk should recurse into directory `full_name` for pathspec `spec`.
845///
846/// Used by revision history path limiting ([`crate::revwalk::rev_list`]) and diff tree pruning.
847/// Matches Git `tree-walk.c` `do_match` recursive
848/// directory interest: a directory is entered when a positive spec's leading literal segment
849/// matches it, or when the pattern begins with a glob metacharacter (`simple_length` zero).
850///
851/// Exclude-only patterns never trigger descent alone.
852#[must_use]
853pub fn pathspec_wants_descent_into_tree(spec: &str, full_name: &str) -> bool {
854    if pathspec_is_exclude(spec) {
855        return false;
856    }
857    let (elem_magic, raw_pattern) = parse_element_magic(spec);
858    let magic = combine_magic(elem_magic);
859    if magic.exclude {
860        return false;
861    }
862    let pattern = strip_top_magic(raw_pattern);
863    let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
864    if pattern.is_empty() || pattern == "." {
865        return true;
866    }
867    if let Some(prefix) = magic.prefix.as_deref() {
868        let base = prefix.trim_end_matches('/');
869        if !base.is_empty() && (full_name == base || base.starts_with(&format!("{full_name}/"))) {
870            return true;
871        }
872    }
873    let dir_prefix = format!("{full_name}/");
874    if pattern.starts_with(&dir_prefix) {
875        return true;
876    }
877    if ps_str_eq(pattern, full_name, magic.icase) {
878        return true;
879    }
880    pathspec_recursive_descent_wanted(pattern, magic, full_name)
881}
882
883fn pathspec_literal_descent_wanted(pattern: &str, dir: &str) -> bool {
884    if pattern.is_empty() {
885        return false;
886    }
887    if let Some(prefix) = pattern.strip_suffix('/') {
888        return dir == prefix || path_is_under_dir(dir, prefix, false);
889    }
890    if dir == pattern {
891        return false;
892    }
893    dir.len() < pattern.len()
894        && path_starts_with(pattern, dir, false)
895        && pattern.as_bytes()[dir.len()] == b'/'
896}
897
898fn pathspec_recursive_descent_wanted(pattern: &str, magic: PathspecMagic, dir: &str) -> bool {
899    if magic.literal {
900        return pathspec_literal_descent_wanted(pattern, dir);
901    }
902    if simple_length(pattern) == 0 {
903        return true;
904    }
905
906    let icase = magic.icase;
907    let glob = magic.glob;
908    let components: Vec<&str> = dir.split('/').filter(|c| !c.is_empty()).collect();
909    pathspec_descent_match_components(pattern, icase, glob, &components)
910}
911
912fn pathspec_first_component(pat: &str) -> (&str, &str) {
913    match pat.find('/') {
914        Some(i) => (&pat[..i], &pat[i + 1..]),
915        None => (pat, ""),
916    }
917}
918
919fn pathspec_dir_name_matches_component(
920    pat_comp: &str,
921    dir_name: &str,
922    icase: bool,
923    glob: bool,
924) -> bool {
925    if pat_comp.is_empty() {
926        return true;
927    }
928    let nwl = simple_length(pat_comp);
929    if nwl == 0 {
930        return true;
931    }
932    if nwl >= pat_comp.len() {
933        return ps_str_eq(pat_comp, dir_name, icase);
934    }
935    let lit = &pat_comp[..nwl];
936    let lit_bytes = lit.as_bytes();
937    let dir_bytes = dir_name.as_bytes();
938    if icase {
939        if dir_bytes.len() < lit_bytes.len()
940            || !dir_bytes[..lit_bytes.len()].eq_ignore_ascii_case(lit_bytes)
941        {
942            return false;
943        }
944    } else if dir_bytes.len() < lit_bytes.len() || &dir_bytes[..lit_bytes.len()] != lit_bytes {
945        return false;
946    }
947    let flags = if icase { WM_CASEFOLD } else { 0 };
948    let wm_flags = if glob { flags | WM_PATHNAME } else { flags };
949    wildmatch(pat_comp.as_bytes(), dir_name.as_bytes(), wm_flags)
950}
951
952fn pathspec_descent_match_components(
953    pat: &str,
954    icase: bool,
955    glob: bool,
956    dir_components: &[&str],
957) -> bool {
958    if dir_components.is_empty() {
959        return simple_length(pat) == 0 || !pat.is_empty();
960    }
961    if simple_length(pat) == 0 {
962        return true;
963    }
964    let (pat_comp, pat_rest) = pathspec_first_component(pat);
965    if !pathspec_dir_name_matches_component(pat_comp, dir_components[0], icase, glob) {
966        return false;
967    }
968    pathspec_descent_match_components(pat_rest, icase, glob, &dir_components[1..])
969}
970
971#[cfg(test)]
972mod pathspec_descent_tests {
973    use super::*;
974
975    #[test]
976    fn descent_wildcard_non_final_component() {
977        assert!(pathspec_wants_descent_into_tree("*/g.c", "a"));
978        assert!(pathspec_wants_descent_into_tree("a/*/f.c", "a"));
979        assert!(pathspec_wants_descent_into_tree("a/*/f.c", "a/b"));
980        assert!(pathspec_wants_descent_into_tree(":(glob)**/f.c", "a"));
981        assert!(pathspec_wants_descent_into_tree(":(glob)*/g.c", "a"));
982        assert!(pathspec_wants_descent_into_tree(":(icase)A/g.c", "a"));
983        assert!(!pathspec_wants_descent_into_tree(":(literal)*g.c", "a"));
984        assert!(!pathspec_wants_descent_into_tree("a/*.c", "b"));
985        assert!(pathspec_wants_descent_into_tree("b*/log.c", "builtin"));
986        assert!(!pathspec_wants_descent_into_tree("b*/log.c", "other"));
987        assert!(pathspec_wants_descent_into_tree("bu?ltin/log.c", "builtin"));
988        assert!(pathspec_wants_descent_into_tree(
989            ":(icase)B*/LOG.C",
990            "builtin"
991        ));
992        assert!(!pathspec_wants_descent_into_tree(":(icase)b*/log.c", "é"));
993    }
994
995    #[test]
996    fn glob_doublestar_path_matches_nested_file() {
997        let specs = vec![":(glob)**/f.c".to_owned()];
998        assert!(matches_pathspec_list("a/b/f.c", &specs));
999    }
1000}
1001
1002/// Like [`matches_pathspec_set_for_object`], but uses `matches_ls_tree_pathspec` for each
1003/// element so `ls-files` / index filtering agrees with `ls-tree` on patterns such as `a[a]`.
1004#[must_use]
1005pub fn matches_pathspec_set_for_object_ls_tree(
1006    specs: &[String],
1007    path: &str,
1008    mode: u32,
1009    attr_rules: &[AttrRule],
1010) -> bool {
1011    if specs.is_empty() {
1012        return true;
1013    }
1014    let mut positives: Vec<&str> = Vec::new();
1015    let mut excludes: Vec<&str> = Vec::new();
1016    for s in specs {
1017        if pathspec_is_exclude(s) {
1018            excludes.push(s.as_str());
1019        } else {
1020            positives.push(s.as_str());
1021        }
1022    }
1023    let positive_ok = if positives.is_empty() {
1024        true
1025    } else {
1026        positives
1027            .iter()
1028            .any(|s| matches_ls_tree_pathspec(s, path, mode, attr_rules))
1029    };
1030    if !positive_ok {
1031        return false;
1032    }
1033    for ex in excludes {
1034        if matches_ls_tree_pathspec(ex, path, mode, attr_rules) {
1035            return false;
1036        }
1037    }
1038    true
1039}
1040
1041/// True if `path` matches the combined pathspec list: any positive spec (or all paths when there
1042/// are only excludes, matching Git `parse_pathspec`), and not matched by any exclude spec.
1043#[must_use]
1044pub fn matches_pathspec_set_for_object(
1045    specs: &[String],
1046    path: &str,
1047    mode: u32,
1048    attr_rules: &[AttrRule],
1049) -> bool {
1050    if specs.is_empty() {
1051        return true;
1052    }
1053    let mut positives: Vec<&str> = Vec::new();
1054    let mut excludes: Vec<&str> = Vec::new();
1055    for s in specs {
1056        if pathspec_is_exclude(s) {
1057            excludes.push(s.as_str());
1058        } else {
1059            positives.push(s.as_str());
1060        }
1061    }
1062    let positive_ok = if positives.is_empty() {
1063        true
1064    } else {
1065        positives
1066            .iter()
1067            .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
1068    };
1069    if !positive_ok {
1070        return false;
1071    }
1072    for ex in excludes {
1073        if matches_pathspec_for_object(ex, path, mode, attr_rules) {
1074            return false;
1075        }
1076    }
1077    true
1078}
1079
1080/// True if `spec` uses `:(top)` or short `:/` (repo-root-relative) magic.
1081#[must_use]
1082pub fn pathspec_has_top(spec: &str) -> bool {
1083    let (elem_magic, _) = parse_element_magic(spec);
1084    combine_magic(elem_magic).top
1085}
1086
1087/// True if `spec` carries an `attr:` clause in long-form `:(...)` magic, so matching it needs
1088/// `.gitattributes` rules. Malformed clauses count too; [`validate_attr_pathspecs`] rejects them.
1089///
1090/// Such specs only match through the attribute-aware entry points:
1091/// [`crate::repo::Repository::pathspec_matcher`], which loads the repository's attributes and
1092/// validates the clauses, or [`matches_pathspec_list_for_object`] with caller-supplied rules.
1093#[must_use]
1094pub(crate) fn pathspec_has_attr_magic(spec: &str) -> bool {
1095    // Attr magic only exists in long form; skip the allocating parse for every other spec.
1096    spec.starts_with(":(") && spec.contains("attr:") && !attr_clauses(spec).is_empty()
1097}
1098
1099/// A resolved pathspec list bound to one repository's matching rules, built by
1100/// [`crate::repo::Repository::pathspec_matcher`].
1101///
1102/// Holds the repository's attributes when a spec carries `:(attr:...)` magic, so testing many
1103/// paths reads them once. Attr requirements see `[attr]` macro expansion and match attribute
1104/// patterns case-insensitively when `core.ignorecase` is set, as Git does. Paths are treated as regular files: a spec ending in `/` does not match
1105/// a path equal to its directory, and `builtin_objectmode` reads as `100644`. Use
1106/// `matches_pathspec_list_for_object` when the entry mode matters.
1107#[derive(Debug, Clone)]
1108pub struct PathspecMatcher<'s> {
1109    specs: Cow<'s, [String]>,
1110    precompose: bool,
1111    attrs: Option<std::sync::Arc<crate::attributes::ParsedGitAttributes>>,
1112    /// Match attribute patterns case-insensitively (`core.ignorecase`).
1113    attr_icase: bool,
1114}
1115
1116impl<'s> PathspecMatcher<'s> {
1117    /// Build a matcher from pathspec strings and optional parsed gitattributes.
1118    pub(crate) fn new(
1119        specs: &'s [String],
1120        precompose: bool,
1121        attrs: Option<std::sync::Arc<crate::attributes::ParsedGitAttributes>>,
1122        attr_icase: bool,
1123    ) -> Self {
1124        // The attribute-aware matcher has no precompose context, so normalize the specs up front.
1125        let specs = if precompose && attrs.is_some() {
1126            Cow::Owned(
1127                specs
1128                    .iter()
1129                    .map(|spec| precompose_utf8_path(spec).into_owned())
1130                    .collect(),
1131            )
1132        } else {
1133            Cow::Borrowed(specs)
1134        };
1135        Self {
1136            specs,
1137            precompose,
1138            attrs,
1139            attr_icase,
1140        }
1141    }
1142
1143    /// Whether `path` is selected: it matches at least one positive spec (any path when there
1144    /// are none or the list is empty) and no `:(exclude)` spec.
1145    #[must_use]
1146    pub fn matches(&self, path: &str) -> bool {
1147        const REGULAR_FILE: u32 = 0o100644;
1148        self.matches_entry(path, REGULAR_FILE)
1149    }
1150
1151    /// Like [`Self::matches`], but uses Git tree/index entry `mode` for trailing-slash and
1152    /// submodule pathspec semantics.
1153    ///
1154    /// Required for path-limited history walks and staging when specs include `:(attr:...)` magic.
1155    #[must_use]
1156    pub fn matches_entry(&self, path: &str, mode: u32) -> bool {
1157        let ctx = context_from_mode_bits(mode);
1158        let match_ctx = PathspecMatchContext {
1159            is_directory: ctx.is_directory,
1160            is_git_submodule: ctx.is_git_submodule,
1161            precompose_paths: self.precompose,
1162        };
1163        let Some(attrs) = &self.attrs else {
1164            return matches_pathspec_list_with_context(path, &self.specs, match_ctx);
1165        };
1166        let path = if self.precompose {
1167            precompose_utf8_path(path)
1168        } else {
1169            Cow::Borrowed(path)
1170        };
1171        let lookup = AttrLookup::new(&attrs.rules, &attrs.macros, self.attr_icase);
1172        matches_pathspec_list_with_attrs(&path, mode, lookup, &self.specs)
1173    }
1174
1175    /// Whether a changed file is selected, checking its current `path` and, for a rename or
1176    /// copy, its `old_path` too (Git limits diffs on either side of a rename).
1177    #[must_use]
1178    pub fn matches_change(&self, path: &str, old_path: Option<&str>) -> bool {
1179        self.specs.is_empty()
1180            || self.matches(path)
1181            || old_path.is_some_and(|old| old != path && self.matches(old))
1182    }
1183
1184    /// Whether the list is empty (every path is selected).
1185    #[must_use]
1186    pub fn is_empty(&self) -> bool {
1187        self.specs.is_empty()
1188    }
1189}
1190
1191fn pathspec_match_one_positive(path: &str, magic: PathspecMagic, raw_pattern: &str) -> bool {
1192    if magic.literal && magic.glob {
1193        return false;
1194    }
1195    let pattern = strip_top_magic(raw_pattern);
1196    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1197        match path_for_match_under_magic_prefix(path, prefix) {
1198            Some(rest) => rest,
1199            None => return false,
1200        }
1201    } else {
1202        path
1203    };
1204    pathspec_matches_tail(pattern, path_for_match, magic)
1205}
1206
1207/// Attribute state that `:(attr:...)` requirements are evaluated against.
1208#[derive(Clone, Copy)]
1209pub(crate) struct AttrLookup<'a> {
1210    rules: &'a [AttrRule],
1211    /// `[attr]` macro definitions; `None` means no macros are defined.
1212    macros: Option<&'a MacroTable>,
1213    /// Match attribute patterns case-insensitively (`core.ignorecase`).
1214    icase: bool,
1215}
1216
1217impl<'a> AttrLookup<'a> {
1218    pub fn new(rules: &'a [AttrRule], macros: &'a MacroTable, icase: bool) -> Self {
1219        Self {
1220            rules,
1221            macros: Some(macros),
1222            icase,
1223        }
1224    }
1225
1226    fn rules_only(rules: &'a [AttrRule]) -> Self {
1227        Self {
1228            rules,
1229            macros: None,
1230            icase: false,
1231        }
1232    }
1233}
1234
1235fn attr_requirements_match(
1236    requirements: &[AttrRequirement],
1237    attrs: AttrLookup<'_>,
1238    path: &str,
1239    is_dir: bool,
1240    mode: u32,
1241) -> bool {
1242    let no_macros = MacroTable::default();
1243    let assigned = path_gitattribute_values(
1244        attrs.rules,
1245        attrs.macros.unwrap_or(&no_macros),
1246        attrs.icase,
1247        path,
1248        is_dir,
1249    );
1250    requirements.iter().all(|req| {
1251        let value: Option<Cow<'_, str>> = if req.name() == "builtin_objectmode" {
1252            (mode != 0).then(|| Cow::Owned(format!("{mode:06o}")))
1253        } else {
1254            assigned
1255                .get(req.name())
1256                .map(|v| Cow::Borrowed(v.display()))
1257                .filter(|v| v != "unspecified")
1258        };
1259        match req {
1260            AttrRequirement::Set(_) => value.as_deref() == Some("set"),
1261            AttrRequirement::Unset(_) => value.as_deref() == Some("unset"),
1262            AttrRequirement::Unspecified(_) => value.is_none(),
1263            AttrRequirement::Value(_, expected) => value.as_deref() == Some(expected.as_str()),
1264        }
1265    })
1266}
1267
1268fn matches_pathspec_element_with_context(
1269    spec: &str,
1270    path: &str,
1271    ctx: PathspecMatchContext,
1272) -> bool {
1273    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1274    let magic = combine_magic(elem_magic);
1275    if magic.exclude {
1276        return false;
1277    }
1278    if magic.literal && magic.glob {
1279        return false;
1280    }
1281    if !magic.attr_requirements.is_empty() {
1282        return false;
1283    }
1284    if magic.literal || magic.glob || magic.icase {
1285        return pathspec_matches(spec, path);
1286    }
1287    let pattern = strip_top_magic(raw_pattern);
1288    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1289        match path_for_match_under_magic_prefix(path, prefix) {
1290            Some(rest) => rest,
1291            None => return false,
1292        }
1293    } else {
1294        path
1295    };
1296    matches_pathspec_with_context(pattern, path_for_match, ctx)
1297}
1298
1299fn pathspec_exclude_element_matches_with_context(
1300    spec: &str,
1301    path: &str,
1302    ctx: PathspecMatchContext,
1303) -> bool {
1304    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1305    let mut magic = combine_magic(elem_magic);
1306    if !magic.exclude {
1307        return false;
1308    }
1309    magic.exclude = false;
1310    if magic.literal && magic.glob {
1311        return false;
1312    }
1313    if !magic.attr_requirements.is_empty() {
1314        // Attribute pathspecs need `.gitattributes` context; use
1315        // [`matches_pathspec_list_for_object`] for those.
1316        return false;
1317    }
1318    if magic.literal || magic.glob || magic.icase {
1319        return pathspec_match_one_positive(path, magic, raw_pattern);
1320    }
1321    let pattern = strip_top_magic(raw_pattern);
1322    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1323        match path_for_match_under_magic_prefix(path, prefix) {
1324            Some(rest) => rest,
1325            None => return false,
1326        }
1327    } else {
1328        path
1329    };
1330    matches_pathspec_with_context(pattern, path_for_match, ctx)
1331}
1332
1333/// True if `path` is matched by an exclude pathspec's pattern. Returns `false` if `spec` is not
1334/// an exclude pathspec.
1335#[must_use]
1336pub fn pathspec_exclude_matches(spec: &str, path: &str) -> bool {
1337    pathspec_exclude_element_matches_with_context(spec, path, PathspecMatchContext::default())
1338}
1339
1340/// When every pathspec is an exclude and none use `:(top)` / `:/`, Git prepends an implicit
1341/// positive that matches only under the process cwd (relative to the work tree), not the whole
1342/// repository (`PATHSPEC_PREFER_CWD` in `pathspec.c`). `cwd_from_repo_root` is that prefix
1343/// without a trailing slash, or empty at the work tree root.
1344#[must_use]
1345pub fn extend_pathspec_list_implicit_cwd(
1346    specs: &[String],
1347    cwd_from_repo_root: Option<&str>,
1348) -> Vec<String> {
1349    if specs.is_empty() {
1350        return specs.to_vec();
1351    }
1352    if !specs.iter().all(|s| pathspec_is_exclude(s)) {
1353        return specs.to_vec();
1354    }
1355    let any_top = specs.iter().any(|s| pathspec_has_top(s));
1356    if any_top {
1357        return specs.to_vec();
1358    }
1359    let Some(cwd) = cwd_from_repo_root.map(str::trim).filter(|s| !s.is_empty()) else {
1360        return specs.to_vec();
1361    };
1362    let cwd = cwd.trim_end_matches('/');
1363    if cwd.is_empty() {
1364        return specs.to_vec();
1365    }
1366    let mut out = Vec::with_capacity(specs.len() + 1);
1367    // Git `parse_pathspec` with `PATHSPEC_PREFER_CWD` adds `.` (see pathspec.c), not `{cwd}/`.
1368    out.push(".".to_owned());
1369    out.extend_from_slice(specs);
1370    out
1371}
1372
1373/// Git `match_pathspec` semantics over a pathspec list: OR of positive specs minus OR of exclude
1374/// specs. If every element is exclude-only, Git implicitly prepends `.` (match all); this
1375/// function does the same.
1376///
1377/// No `.gitattributes` rules are available here, so a positive `:(attr:...)` spec never matches
1378/// and an exclude `:(exclude,attr:...)` spec never excludes. Use
1379/// [`crate::repo::Repository::paths_match_pathspecs`] or [`matches_pathspec_list_for_object`]
1380/// when specs may carry attr magic (see `pathspec_has_attr_magic`).
1381#[must_use]
1382pub fn matches_pathspec_list(path: &str, specs: &[String]) -> bool {
1383    matches_pathspec_list_with_context(path, specs, PathspecMatchContext::default())
1384}
1385
1386/// Like [`matches_pathspec_list`], but uses `ctx` for non-magic pathspec elements (trailing `/`).
1387///
1388/// Like [`matches_pathspec_list`], this does not evaluate `:(attr:...)` magic: positive attr specs
1389/// never match and attr excludes never exclude.
1390#[must_use]
1391pub fn matches_pathspec_list_with_context(
1392    path: &str,
1393    specs: &[String],
1394    ctx: PathspecMatchContext,
1395) -> bool {
1396    if specs.is_empty() {
1397        return true;
1398    }
1399    let mut has_exclude = false;
1400    let mut has_positive = false;
1401    let mut positive = false;
1402    for spec in specs {
1403        if pathspec_is_exclude(spec) {
1404            has_exclude = true;
1405        } else {
1406            has_positive = true;
1407            positive = positive || matches_pathspec_element_with_context(spec, path, ctx);
1408        }
1409    }
1410    if has_positive && !positive {
1411        return false;
1412    }
1413    if !has_exclude {
1414        return true;
1415    }
1416    let excluded = specs.iter().any(|s| {
1417        pathspec_is_exclude(s) && pathspec_exclude_element_matches_with_context(s, path, ctx)
1418    });
1419    !excluded
1420}
1421
1422/// Whether any of `sorted_paths` matches the single pathspec `spec`.
1423///
1424/// Same result as testing every path with [`matches_pathspec_list_with_context`] and `[spec]`,
1425/// but a plain literal pathspec (no magic, no wildcards, no `./`) is answered with a binary
1426/// search instead of a scan, so checking many pathspecs against a large index stays cheap.
1427///
1428/// `sorted_paths` must be sorted in byte order (the [`str`] ordering) for the result to be
1429/// correct; duplicates are allowed.
1430pub(crate) fn sorted_paths_match_pathspec<S: AsRef<str>>(
1431    spec: &str,
1432    sorted_paths: &[S],
1433    ctx: PathspecMatchContext,
1434) -> bool {
1435    let list = [spec.to_owned()];
1436    let matches_linearly = |p: &S| matches_pathspec_list_with_context(p.as_ref(), &list, ctx);
1437    if !is_plain_literal_pathspec(spec, ctx) {
1438        return sorted_paths.iter().any(matches_linearly);
1439    }
1440    let spec_nfc = literal_spec_for_lookup(spec, ctx);
1441    if sorted_literal_lookup(&spec_nfc, sorted_paths) {
1442        return true;
1443    }
1444    // A hit on the NFC spelling is always a match; a path stored in another normalization form
1445    // can only match through a non-ASCII segment, so a miss rechecks just those paths.
1446    ctx.precompose_paths
1447        && sorted_paths
1448            .iter()
1449            .filter(|p| !p.as_ref().is_ascii())
1450            .any(matches_linearly)
1451}
1452
1453/// Binary-search `sorted_paths` for the plain literal `spec` (exact path or directory prefix).
1454fn sorted_literal_lookup<S: AsRef<str>>(spec: &str, sorted_paths: &[S]) -> bool {
1455    let has_path_with_prefix = |prefix: &str| {
1456        let at = sorted_paths.partition_point(|p| p.as_ref() < prefix);
1457        sorted_paths
1458            .get(at)
1459            .is_some_and(|p| p.as_ref().starts_with(prefix))
1460    };
1461    if spec.ends_with('/') {
1462        return has_path_with_prefix(spec);
1463    }
1464    let at = sorted_paths.partition_point(|p| p.as_ref() < spec);
1465    if sorted_paths.get(at).is_some_and(|p| p.as_ref() == spec) {
1466        return true;
1467    }
1468    has_path_with_prefix(&format!("{spec}/"))
1469}
1470
1471/// The spelling of a plain literal `spec` that [`matches_pathspec_with_context`] compares paths
1472/// against: NFC when the context precomposes paths, else `spec` itself.
1473pub(crate) fn literal_spec_for_lookup(spec: &str, ctx: PathspecMatchContext) -> Cow<'_, str> {
1474    if ctx.precompose_paths {
1475        precompose_utf8_path(spec)
1476    } else {
1477        Cow::Borrowed(spec)
1478    }
1479}
1480
1481/// Repository-relative paths when every spec is a plain literal file path (no trailing `/`).
1482///
1483/// Used to narrow index/worktree diffs to a small path set without scanning the full index.
1484/// Returns `None` when `ctx` precomposes paths: another normalization form of a spec could also
1485/// match, and only a scan of the candidate paths can find it.
1486pub(crate) fn exact_literal_pathspec_set(
1487    specs: &[String],
1488    ctx: PathspecMatchContext,
1489) -> Option<std::collections::BTreeSet<String>> {
1490    if specs.is_empty() || ctx.precompose_paths {
1491        return None;
1492    }
1493    if !specs
1494        .iter()
1495        .all(|s| is_plain_literal_pathspec(s, ctx) && !s.ends_with('/'))
1496    {
1497        return None;
1498    }
1499    Some(specs.iter().cloned().collect())
1500}
1501
1502/// Whether `spec` matches exactly the path `spec` and everything under directory `spec`
1503/// (no magic, no wildcards, no `./`, and no context that changes trailing-`/` handling).
1504///
1505/// With [`PathspecMatchContext::precompose_paths`], "the path `spec`" means equality after NFC
1506/// normalization of both sides; see [`literal_spec_for_lookup`].
1507pub(crate) fn is_plain_literal_pathspec(spec: &str, ctx: PathspecMatchContext) -> bool {
1508    !ctx.is_directory
1509        && !ctx.is_git_submodule
1510        && !spec.is_empty()
1511        && spec != "."
1512        && !spec.starts_with("./")
1513        && !spec.starts_with(':')
1514        && simple_length(spec) == spec.len()
1515        && !(literal_global() || glob_global() || noglob_global() || icase_global())
1516}
1517
1518/// Encode an exact repository-relative path for pathspec-limited diffs and Bloom prechecks.
1519///
1520/// Tree diffs and skip-applied filters collect literal object paths. Names may begin with `:`
1521/// or contain glob metacharacters, so they must not be passed as raw user pathspec syntax.
1522pub(crate) fn repository_path_as_literal_pathspec(path: &str) -> String {
1523    let ctx = PathspecMatchContext::default();
1524    if is_plain_literal_pathspec(path, ctx) {
1525        path.to_owned()
1526    } else {
1527        format!(":(literal){path}")
1528    }
1529}
1530
1531/// A pathspec list prepared once for matching many paths with the same context.
1532///
1533/// Gives the same answers as [`matches_pathspec_list_with_context`] with the list and context
1534/// it was built from. When every element is a plain literal (see
1535/// [`sorted_paths_match_pathspec`]), a path is answered with hash lookups of the path and its
1536/// leading directories instead of comparing it against every element, which keeps matching a
1537/// large index against many pathspecs linear in the index size.
1538pub(crate) struct PathspecListMatcher<'a> {
1539    specs: &'a [String],
1540    ctx: PathspecMatchContext,
1541    literals: Option<LiteralPathspecSet>,
1542}
1543
1544/// Plain literal pathspecs split by how they match, spelled as [`literal_spec_for_lookup`] does.
1545struct LiteralPathspecSet {
1546    /// Specs without a trailing `/`: they match the path itself.
1547    exact: HashSet<String>,
1548    /// Directories whose contents match: every spec, with one trailing `/` removed.
1549    dirs: HashSet<String>,
1550}
1551
1552impl<'a> PathspecListMatcher<'a> {
1553    /// Prepare `specs` for repeated matching under `ctx`.
1554    pub fn new(specs: &'a [String], ctx: PathspecMatchContext) -> Self {
1555        let all_plain =
1556            !specs.is_empty() && specs.iter().all(|s| is_plain_literal_pathspec(s, ctx));
1557        let literals = all_plain.then(|| {
1558            let mut set = LiteralPathspecSet {
1559                exact: HashSet::with_capacity(specs.len()),
1560                dirs: HashSet::with_capacity(specs.len()),
1561            };
1562            for spec in specs {
1563                let spec = literal_spec_for_lookup(spec, ctx);
1564                match spec.strip_suffix('/') {
1565                    Some(dir) => {
1566                        set.dirs.insert(dir.to_owned());
1567                    }
1568                    None => {
1569                        set.exact.insert(spec.to_string());
1570                        set.dirs.insert(spec.into_owned());
1571                    }
1572                }
1573            }
1574            set
1575        });
1576        Self {
1577            specs,
1578            ctx,
1579            literals,
1580        }
1581    }
1582
1583    /// Whether `path` matches the pathspec list.
1584    pub(crate) fn matches(&self, path: &str) -> bool {
1585        let Some(set) = &self.literals else {
1586            return matches_pathspec_list_with_context(path, self.specs, self.ctx);
1587        };
1588        let path = literal_spec_for_lookup(path, self.ctx);
1589        let path = path.as_ref();
1590        if set.exact.contains(path) {
1591            return true;
1592        }
1593        path.bytes()
1594            .enumerate()
1595            .any(|(i, b)| b == b'/' && set.dirs.contains(&path[..i]))
1596    }
1597}
1598
1599/// `matches_pathspec_list` for tree/index objects with mode and `.gitattributes` rules.
1600///
1601/// `attr_rules` are applied without `[attr]` macro definitions and with case-sensitive
1602/// patterns; [`crate::repo::Repository::pathspec_matcher`] also honors macros and
1603/// `core.ignorecase`.
1604#[must_use]
1605pub fn matches_pathspec_list_for_object(
1606    path: &str,
1607    mode: u32,
1608    attr_rules: &[AttrRule],
1609    specs: &[String],
1610) -> bool {
1611    matches_pathspec_list_with_attrs(path, mode, AttrLookup::rules_only(attr_rules), specs)
1612}
1613
1614pub(crate) fn matches_pathspec_list_with_attrs(
1615    path: &str,
1616    mode: u32,
1617    attrs: AttrLookup<'_>,
1618    specs: &[String],
1619) -> bool {
1620    if specs.is_empty() {
1621        return true;
1622    }
1623    let mut has_exclude = false;
1624    let mut has_positive = false;
1625    let mut positive = false;
1626    for spec in specs {
1627        if pathspec_is_exclude(spec) {
1628            has_exclude = true;
1629        } else {
1630            has_positive = true;
1631            positive = positive || matches_pathspec_for_object_with_attrs(spec, path, mode, attrs);
1632        }
1633    }
1634    if has_positive && !positive {
1635        return false;
1636    }
1637    if !has_exclude {
1638        return true;
1639    }
1640    !specs.iter().any(|s| {
1641        pathspec_is_exclude(s) && matches_pathspec_exclude_for_object(s, path, mode, attrs)
1642    })
1643}
1644
1645fn matches_pathspec_exclude_for_object(
1646    spec: &str,
1647    path: &str,
1648    mode: u32,
1649    attrs: AttrLookup<'_>,
1650) -> bool {
1651    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1652    let mut magic = combine_magic(elem_magic);
1653    if !magic.exclude {
1654        return false;
1655    }
1656    magic.exclude = false;
1657    if magic.literal && magic.glob {
1658        return false;
1659    }
1660    let ctx = context_from_mode_bits(mode);
1661    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1662    if !magic.attr_requirements.is_empty()
1663        && !attr_requirements_match(&magic.attr_requirements, attrs, path, is_dir_for_attr, mode)
1664    {
1665        return false;
1666    }
1667    let pattern = strip_top_magic(raw_pattern);
1668    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1669        match path_for_match_under_magic_prefix(path, prefix) {
1670            Some(rest) => rest,
1671            None => return false,
1672        }
1673    } else {
1674        path
1675    };
1676    if magic.literal || magic.glob || magic.icase {
1677        pathspec_matches_tail(pattern, path_for_match, magic)
1678    } else {
1679        matches_pathspec_with_context(pattern, path_for_match, ctx)
1680    }
1681}
1682
1683fn pathspec_matches_tail(pattern: &str, path: &str, magic: PathspecMagic) -> bool {
1684    if pattern.is_empty() {
1685        return true;
1686    }
1687
1688    let flags = if magic.icase { WM_CASEFOLD } else { 0 };
1689
1690    if magic.literal {
1691        return literal_prefix_match(pattern, path);
1692    }
1693
1694    let wm_flags = if magic.glob {
1695        flags | WM_PATHNAME
1696    } else {
1697        flags
1698    };
1699
1700    let pattern_bytes = pattern.as_bytes();
1701    let path_bytes = path.as_bytes();
1702    let simple = simple_length(pattern);
1703
1704    // Git `match_pathspec_item`: exact / directory prefix before `git_fnmatch`.
1705    // Only when the pattern has no glob metacharacters (`simple_length` spans the whole pattern);
1706    // otherwise a pattern like `a[a]` must not match children via `a[a]/` prefix (t6130 vs ls-tree).
1707    if ps_str_eq(pattern, path, magic.icase) {
1708        return true;
1709    }
1710    if simple == pattern.len() {
1711        if let Some(prefix) = pattern.strip_suffix('/') {
1712            if ps_str_eq(prefix, path, magic.icase) {
1713                return true;
1714            }
1715            if path_is_under_dir(path, prefix, magic.icase) {
1716                return true;
1717            }
1718        } else if path_is_under_dir(path, pattern, magic.icase) {
1719            return true;
1720        }
1721    }
1722
1723    // `:(glob)**/*.txt` at repo root: Git matches `untracked.txt` (leading `**/` is optional).
1724    if magic.glob && !path.contains('/') && pattern.starts_with("**/") {
1725        if wildmatch(pattern_bytes, path_bytes, wm_flags) {
1726            return true;
1727        }
1728        if let Some(suffix) = pattern.strip_prefix("**/") {
1729            if wildmatch(suffix.as_bytes(), path_bytes, wm_flags) {
1730                return true;
1731            }
1732        }
1733    }
1734
1735    // Wildcard: require literal bytes up to `simple_length`, then wildmatch the tail only.
1736    if simple < pattern.len() {
1737        if path_bytes.len() < simple {
1738            return false;
1739        }
1740        let path_lit = &path_bytes[..simple];
1741        let pat_lit = &pattern_bytes[..simple];
1742        let same = if magic.icase {
1743            path_lit.eq_ignore_ascii_case(pat_lit)
1744        } else {
1745            path_lit == pat_lit
1746        };
1747        if !same {
1748            return false;
1749        }
1750        let pat_rest = &pattern[simple..];
1751        let path_rest = &path[simple..];
1752        return wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), wm_flags);
1753    }
1754
1755    ps_str_eq(pattern, path, magic.icase) || path_is_under_dir(path, pattern, magic.icase)
1756}
1757
1758fn ps_str_eq(a: &str, b: &str, icase: bool) -> bool {
1759    if icase {
1760        a.eq_ignore_ascii_case(b)
1761    } else {
1762        a == b
1763    }
1764}
1765
1766fn path_starts_with(path: &str, prefix: &str, icase: bool) -> bool {
1767    if icase {
1768        path.get(..prefix.len())
1769            .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
1770    } else {
1771        path.starts_with(prefix)
1772    }
1773}
1774
1775/// Whether `path` starts with `dir` followed by `/` (the path lies inside directory `dir`).
1776fn path_is_under_dir(path: &str, dir: &str, icase: bool) -> bool {
1777    path.as_bytes().get(dir.len()) == Some(&b'/') && path_starts_with(path, dir, icase)
1778}
1779
1780fn literal_prefix_match(pattern: &str, path: &str) -> bool {
1781    if let Some(prefix) = pattern.strip_suffix('/') {
1782        return path == prefix || path_is_under_dir(path, prefix, false);
1783    }
1784    path == pattern || path_is_under_dir(path, pattern, false)
1785}
1786
1787/// Literal pathspec match for `ls-tree` when the pattern has no `*`/`?` (brackets stay literal).
1788fn ls_tree_literal_match(pattern: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1789    if let Some(prefix) = pattern.strip_suffix('/') {
1790        if path_is_under_dir(path, prefix, false) {
1791            return true;
1792        }
1793        if path == prefix {
1794            return ctx.is_directory || ctx.is_git_submodule;
1795        }
1796        return false;
1797    }
1798    path == pattern || path_is_under_dir(path, pattern, false)
1799}
1800
1801/// Optional path metadata for literal pathspecs with a trailing `/` (tree-walk / diff-tree).
1802///
1803/// Git treats `dir/` as “directory or git submodule only”: a regular file `dir`
1804/// does not match, but a tree entry `dir` or gitlink `dir` does.
1805#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1806pub struct PathspecMatchContext {
1807    /// The index/tree entry is a directory (mode `040000`).
1808    pub is_directory: bool,
1809    /// The entry is a git submodule / gitlink (`160000`).
1810    pub is_git_submodule: bool,
1811    /// NFC-normalize pathspec and path when matching (per-repository `core.precomposeunicode`).
1812    pub precompose_paths: bool,
1813}
1814
1815/// Returns whether `path` matches the pathspec `spec` with default (file) context.
1816///
1817/// For pathspecs ending in `/`, a path equal to the prefix matches only when
1818/// [`PathspecMatchContext`] indicates a directory or submodule; see
1819/// [`matches_pathspec_with_context`].
1820///
1821/// `:(attr:...)` requirements are not evaluated here (only the pattern part is matched); use
1822/// `matches_pathspec_for_object` or [`crate::repo::Repository::paths_match_pathspecs`] for
1823/// specs where `pathspec_has_attr_magic` is true.
1824#[must_use]
1825pub fn matches_pathspec(spec: &str, path: &str) -> bool {
1826    matches_pathspec_with_context(spec, path, PathspecMatchContext::default())
1827}
1828
1829/// Like [`matches_pathspec`], but uses `ctx` for trailing-`/` literal pathspecs and for
1830/// wildcard pathspecs where the pattern continues after a directory boundary (Git
1831/// `matches_pathspec` + directory semantics).
1832#[must_use]
1833pub fn matches_pathspec_with_context(spec: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1834    let spec_nfc: Cow<'_, str> = if ctx.precompose_paths {
1835        precompose_utf8_path(spec)
1836    } else {
1837        Cow::Borrowed(spec)
1838    };
1839    let path_nfc: Cow<'_, str> = if ctx.precompose_paths {
1840        precompose_utf8_path(path)
1841    } else {
1842        Cow::Borrowed(path)
1843    };
1844    let spec = spec_nfc.as_ref();
1845    let path = path_nfc.as_ref();
1846
1847    let trimmed = spec.strip_prefix("./").unwrap_or(spec);
1848    if trimmed == "." || trimmed.is_empty() {
1849        return true;
1850    }
1851
1852    let (elem_magic, raw_pattern) = parse_element_magic(trimmed);
1853    let magic = combine_magic(elem_magic);
1854
1855    if magic.literal && magic.glob {
1856        return false;
1857    }
1858    if magic.exclude {
1859        return false;
1860    }
1861
1862    let pattern = strip_top_magic(raw_pattern);
1863    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1864        match path_for_match_under_magic_prefix(path, prefix) {
1865            Some(rest) => rest,
1866            None => return false,
1867        }
1868    } else {
1869        path
1870    };
1871
1872    if magic.literal {
1873        if let Some(prefix) = pattern.strip_suffix('/') {
1874            if path_is_under_dir(path_for_match, prefix, false) {
1875                return true;
1876            }
1877            if path_for_match == prefix {
1878                return ctx.is_directory || ctx.is_git_submodule;
1879            }
1880            return false;
1881        }
1882        return path_for_match == pattern || path_is_under_dir(path_for_match, pattern, false);
1883    }
1884
1885    // No wildcards and trailing `/`: directory-only semantics (Git `matches_pathspec`).
1886    if let Some(prefix) = pattern.strip_suffix('/') {
1887        if simple_length(pattern) == pattern.len() {
1888            if path_is_under_dir(path_for_match, prefix, false) {
1889                return true;
1890            }
1891            if path_for_match == prefix {
1892                return ctx.is_directory || ctx.is_git_submodule;
1893            }
1894            return false;
1895        }
1896    }
1897
1898    if pathspec_matches_tail(pattern, path_for_match, magic) {
1899        return true;
1900    }
1901
1902    if (ctx.is_directory || ctx.is_git_submodule)
1903        && !path_for_match.is_empty()
1904        && pattern.len() > path_for_match.len()
1905        && pattern.as_bytes().get(path_for_match.len()) == Some(&b'/')
1906        && pattern.starts_with(path_for_match)
1907        && simple_length(pattern) < pattern.len()
1908    {
1909        return true;
1910    }
1911
1912    false
1913}
1914
1915/// Parse a Git mode string (e.g. `100644`, `040000`) into a [`PathspecMatchContext`].
1916#[must_use]
1917pub fn context_from_mode_octal(mode: &str) -> PathspecMatchContext {
1918    let Ok(bits) = u32::from_str_radix(mode, 8) else {
1919        return PathspecMatchContext::default();
1920    };
1921    context_from_mode_bits(bits)
1922}
1923
1924/// Classify a raw Git mode (e.g. from an index or tree entry) for pathspec matching.
1925#[must_use]
1926pub(crate) fn context_from_mode_bits(mode: u32) -> PathspecMatchContext {
1927    let ty = mode & 0o170000;
1928    PathspecMatchContext {
1929        is_directory: ty == 0o040000,
1930        is_git_submodule: ty == 0o160000,
1931        precompose_paths: false,
1932    }
1933}
1934
1935/// Pathspec matching for `ls-tree` after Git forces `pathspec.has_wildcard = 0` (`ls-tree.c`).
1936///
1937/// Metacharacters `*` / `?` still participate in [`wildmatch`]; `[` and `\\` are **not** glob
1938/// starters unless a `*` or `?` appears — so `a[a]` matches the literal directory `a[a]` (t3102),
1939/// while `a*` matches `a/one`, `aa/two`, `a[a]/three`, …
1940#[must_use]
1941pub(crate) fn matches_ls_tree_pathspec(
1942    spec: &str,
1943    path: &str,
1944    mode: u32,
1945    attr_rules: &[AttrRule],
1946) -> bool {
1947    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1948    let mut magic = combine_magic(elem_magic);
1949    magic.exclude = false;
1950
1951    if magic.literal && magic.glob {
1952        return false;
1953    }
1954
1955    let ctx = context_from_mode_bits(mode);
1956    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1957
1958    if !magic.attr_requirements.is_empty()
1959        && !attr_requirements_match(
1960            &magic.attr_requirements,
1961            AttrLookup::rules_only(attr_rules),
1962            path,
1963            is_dir_for_attr,
1964            mode,
1965        )
1966    {
1967        return false;
1968    }
1969
1970    let pattern = strip_top_magic(raw_pattern);
1971    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1972        match path_for_match_under_magic_prefix(path, prefix) {
1973            Some(rest) => rest,
1974            None => return false,
1975        }
1976    } else {
1977        path
1978    };
1979
1980    if magic.literal || magic.glob || magic.icase {
1981        return pathspec_matches_tail(pattern, path_for_match, magic);
1982    }
1983
1984    let spec_nfc: Cow<'_, str> = Cow::Borrowed(pattern);
1985    let path_nfc: Cow<'_, str> = Cow::Borrowed(path_for_match);
1986    let pattern = spec_nfc.as_ref();
1987    let path = path_nfc.as_ref();
1988
1989    let trimmed = pattern.strip_prefix("./").unwrap_or(pattern);
1990    if trimmed == "." || trimmed.is_empty() {
1991        return true;
1992    }
1993
1994    let uses_star_or_question = trimmed.contains('*') || trimmed.contains('?');
1995    if !uses_star_or_question {
1996        return ls_tree_literal_match(trimmed, path, ctx);
1997    }
1998
1999    let nwl = simple_length(trimmed);
2000    let flags = 0u32;
2001    if nwl == trimmed.len() {
2002        return wildmatch(trimmed.as_bytes(), path.as_bytes(), flags);
2003    }
2004    let lit = trimmed.as_bytes().get(..nwl).unwrap_or_default();
2005    let path_b = path.as_bytes();
2006    if path_b.len() < nwl {
2007        return false;
2008    }
2009    if &path_b[..nwl] != lit {
2010        return false;
2011    }
2012    let pat_rest = &trimmed[nwl..];
2013    let path_rest = &path[nwl..];
2014    wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), flags)
2015}
2016
2017/// Match a pathspec against a tree path, using `.gitattributes` for `:(attr:...)`.
2018///
2019/// `mode` supplies directory/gitlink context for literal pathspecs ending in `/`.
2020#[must_use]
2021pub(crate) fn matches_pathspec_for_object(
2022    spec: &str,
2023    path: &str,
2024    mode: u32,
2025    attr_rules: &[AttrRule],
2026) -> bool {
2027    matches_pathspec_for_object_with_attrs(spec, path, mode, AttrLookup::rules_only(attr_rules))
2028}
2029
2030fn matches_pathspec_for_object_with_attrs(
2031    spec: &str,
2032    path: &str,
2033    mode: u32,
2034    attrs: AttrLookup<'_>,
2035) -> bool {
2036    let (elem_magic, raw_pattern) = parse_element_magic(spec);
2037    let mut magic = combine_magic(elem_magic);
2038    magic.exclude = false;
2039
2040    if magic.literal && magic.glob {
2041        return false;
2042    }
2043
2044    let ctx = context_from_mode_bits(mode);
2045    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
2046
2047    if !magic.attr_requirements.is_empty()
2048        && !attr_requirements_match(&magic.attr_requirements, attrs, path, is_dir_for_attr, mode)
2049    {
2050        return false;
2051    }
2052
2053    let pattern = strip_top_magic(raw_pattern);
2054    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
2055        match path_for_match_under_magic_prefix(path, prefix) {
2056            Some(rest) => rest,
2057            None => return false,
2058        }
2059    } else {
2060        path
2061    };
2062    if magic.literal || magic.glob || magic.icase {
2063        pathspec_matches_tail(pattern, path_for_match, magic)
2064    } else {
2065        matches_pathspec_with_context(pattern, path_for_match, ctx)
2066    }
2067}
2068
2069/// Resolved path lies outside the repository work tree (Git `prefix_path_gently` failure).
2070#[derive(Debug, Clone)]
2071pub struct PathOutsideRepository {
2072    /// User-facing pathspec token (argv element).
2073    pub elt: String,
2074    /// Resolved absolute path outside the work tree.
2075    pub path: String,
2076    /// Canonical work tree root.
2077    pub work_tree: PathBuf,
2078}
2079
2080impl std::fmt::Display for PathOutsideRepository {
2081    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2082        write!(
2083            f,
2084            "{}: '{}' is outside repository at '{}'",
2085            self.elt,
2086            self.path,
2087            self.work_tree.display()
2088        )
2089    }
2090}
2091
2092impl From<PathOutsideRepository> for PathspecResolveError {
2093    fn from(outside: PathOutsideRepository) -> Self {
2094        Self::OutsideRepository {
2095            elt: outside.elt,
2096            path: outside.path,
2097            work_tree: outside.work_tree.display().to_string(),
2098        }
2099    }
2100}
2101
2102/// Resolve a magic pathspec relative to a current-directory prefix.
2103///
2104/// This keeps the `cwd` prefix case-sensitive (via an internal `prefix:` magic
2105/// token) while still honoring magic options like `icase` for the tail.
2106/// Specs that include `top` (or short `:/`) stay repo-root-relative: the tail is
2107/// not prefixed with the process cwd and no `prefix:` token is injected.
2108/// Returns `None` when `spec` is not a parseable magic pathspec.
2109pub fn resolve_magic_pathspec(spec: &str, cwd_prefix: &str) -> Option<String> {
2110    if !spec.starts_with(":(") {
2111        return None;
2112    }
2113    let close_idx = spec.find(')')?;
2114    let magic_prefix = &spec[..=close_idx];
2115    let tail = &spec[close_idx + 1..];
2116    Some(resolve_magic_pathspec_parts(magic_prefix, tail, cwd_prefix))
2117}
2118
2119/// Whether `token` is a long-form magic pathspec (`:(...)`) rather than a revision name.
2120#[must_use]
2121pub fn is_long_magic_pathspec_token(token: &str) -> bool {
2122    resolve_magic_pathspec(token, "").is_some()
2123}
2124
2125fn resolve_magic_pathspec_parts(magic_prefix: &str, tail: &str, cwd_prefix: &str) -> String {
2126    if has_magic_prefix_token(magic_prefix) {
2127        return format!("{magic_prefix}{tail}");
2128    }
2129
2130    if let Some(rooted_tail) = tail.strip_prefix('/') {
2131        return format!("{magic_prefix}{}", normalize_relative_path_str(rooted_tail));
2132    }
2133
2134    if long_magic_prefix_has_top(magic_prefix) {
2135        return format!("{magic_prefix}{tail}");
2136    }
2137
2138    let combined = if cwd_prefix.is_empty() {
2139        normalize_relative_path_str(tail)
2140    } else {
2141        normalize_relative_path_str(&format!("{cwd_prefix}{tail}"))
2142    };
2143
2144    let cwd_base = normalize_relative_path_str(cwd_prefix.trim_end_matches('/'));
2145    if !cwd_base.is_empty()
2146        && (combined == cwd_base || combined.starts_with(&format!("{cwd_base}/")))
2147    {
2148        let after_base = combined
2149            .strip_prefix(&cwd_base)
2150            .unwrap_or(combined.as_str());
2151        let remainder = after_base.strip_prefix('/').unwrap_or(after_base);
2152        let magic_with_prefix = inject_magic_prefix_token(magic_prefix, &format!("{cwd_base}/"));
2153        return format!("{magic_with_prefix}{remainder}");
2154    }
2155
2156    format!("{magic_prefix}{combined}")
2157}
2158
2159fn has_magic_prefix_token(magic_prefix: &str) -> bool {
2160    let Some(inner) = magic_prefix
2161        .strip_prefix(":(")
2162        .and_then(|s| s.strip_suffix(')'))
2163    else {
2164        return false;
2165    };
2166    inner
2167        .split(',')
2168        .map(str::trim)
2169        .any(|token| token.starts_with("prefix:"))
2170}
2171
2172fn long_magic_prefix_has_top(magic_prefix: &str) -> bool {
2173    let Some(inner) = magic_prefix
2174        .strip_prefix(":(")
2175        .and_then(|s| s.strip_suffix(')'))
2176    else {
2177        return false;
2178    };
2179    inner
2180        .split(',')
2181        .map(str::trim)
2182        .any(|token| token.eq_ignore_ascii_case("top"))
2183}
2184
2185fn inject_magic_prefix_token(magic_prefix: &str, prefix: &str) -> String {
2186    let Some(inner) = magic_prefix
2187        .strip_prefix(":(")
2188        .and_then(|s| s.strip_suffix(')'))
2189    else {
2190        return magic_prefix.to_string();
2191    };
2192    if inner.trim().is_empty() {
2193        format!(":(prefix:{prefix})")
2194    } else {
2195        format!(":({inner},prefix:{prefix})")
2196    }
2197}
2198
2199/// Drop repeated leading `./` / `.//` from a repo-root pathspec (Git `prefix_path` behavior).
2200///
2201/// When stripping exposes a leading `:`, wrap with `:(literal)` so a filesystem path like
2202/// `:!foo` is not reinterpreted as short exclude magic.
2203fn normalize_repo_root_relative_pathspec(pathspec: &str) -> String {
2204    let mut spec = pathspec;
2205    let mut stripped = false;
2206    while let Some(rest) = spec.strip_prefix("./") {
2207        stripped = true;
2208        spec = rest.trim_start_matches('/');
2209    }
2210    if !stripped {
2211        return pathspec.to_owned();
2212    }
2213    if spec.is_empty() {
2214        return ".".to_owned();
2215    }
2216    if spec.starts_with(':') && !literal_pathspecs_enabled() {
2217        return format!(":(literal){spec}");
2218    }
2219    spec.to_owned()
2220}
2221
2222/// Normalize user pathspec separators on Windows (`\` → `/`) before resolution.
2223fn normalize_pathspec_separators(pathspec: &str) -> std::borrow::Cow<'_, str> {
2224    #[cfg(windows)]
2225    {
2226        if !pathspec.starts_with(':') && pathspec.contains('\\') {
2227            return std::borrow::Cow::Owned(pathspec.replace('\\', "/"));
2228        }
2229    }
2230    std::borrow::Cow::Borrowed(pathspec)
2231}
2232
2233fn normalize_relative_path_str(path: &str) -> String {
2234    let mut parts: Vec<String> = Vec::new();
2235    for component in std::path::Path::new(path).components() {
2236        match component {
2237            std::path::Component::CurDir => {}
2238            std::path::Component::ParentDir => {
2239                parts.pop();
2240            }
2241            std::path::Component::Normal(seg) => {
2242                parts.push(seg.to_string_lossy().to_string());
2243            }
2244            std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
2245        }
2246    }
2247    parts.join("/")
2248}
2249
2250/// Current directory relative to `work_tree`, or `None` if cwd is the work tree root.
2251#[must_use]
2252pub fn pathdiff(cwd: &Path, work_tree: &Path) -> Option<String> {
2253    use crate::git_path::{path_for_disk_compare, path_to_git_slash};
2254
2255    let cwd_cmp = path_for_disk_compare(cwd);
2256    let wt_cmp = path_for_disk_compare(work_tree);
2257
2258    if cwd_cmp == wt_cmp {
2259        return None;
2260    }
2261
2262    let rel = cwd_cmp.strip_prefix(&wt_cmp).ok()?;
2263    let git = path_to_git_slash(rel);
2264    if git.is_empty() {
2265        None
2266    } else {
2267        Some(git)
2268    }
2269}
2270
2271/// For short exclude magic from a subdirectory, Git applies the worktree prefix to the pattern
2272/// tail (via `prefix_path`) while keeping argv spelling. Long-form `:(exclude,prefix:…)` matches
2273/// [`resolve_worktree_pathspec`] for `:(exclude)…` under a cwd prefix.
2274fn prepend_cwd_to_short_exclude_pathspec(spec: &str, cwd: &str) -> Option<String> {
2275    let cwd = cwd.trim_end_matches('/');
2276    if cwd.is_empty() {
2277        return None;
2278    }
2279    let (elem_magic, pattern) = parse_element_magic(spec);
2280    let magic = combine_magic(elem_magic);
2281    if !magic.exclude || magic.top {
2282        return None;
2283    }
2284    if pattern.is_empty() || pattern.starts_with('/') {
2285        return None;
2286    }
2287    if magic.glob || magic.literal || magic.icase || !magic.attr_requirements.is_empty() {
2288        return None;
2289    }
2290    Some(format!(":(exclude,prefix:{cwd}/){pattern}"))
2291}
2292
2293/// Build [`PathspecMatchContext`] with optional NFC path normalization for comparisons.
2294#[must_use]
2295pub(crate) fn pathspec_match_context_with_precompose(
2296    precompose_paths: bool,
2297) -> PathspecMatchContext {
2298    PathspecMatchContext {
2299        precompose_paths,
2300        ..PathspecMatchContext::default()
2301    }
2302}
2303
2304/// Like [`matches_pathspec_list`], with an explicit `precompose_paths` flag.
2305#[must_use]
2306pub fn matches_pathspec_list_precompose(
2307    path: &str,
2308    specs: &[String],
2309    precompose_paths: bool,
2310) -> bool {
2311    matches_pathspec_list_with_context(
2312        path,
2313        specs,
2314        pathspec_match_context_with_precompose(precompose_paths),
2315    )
2316}
2317
2318/// True when argv pathspec strings must not be NFC-normalized (magic syntax).
2319#[must_use]
2320pub(crate) fn argv_pathspec_preserves_spelling(pathspec: &str) -> bool {
2321    pathspec.starts_with(":(")
2322        || pathspec.starts_with(":/")
2323        || pathspec.starts_with(":!")
2324        || pathspec.starts_with(":^")
2325}
2326
2327/// NFC-normalize a literal command-line pathspec (Git `precompose_argv_prefix`).
2328#[must_use]
2329pub fn precompose_argv_pathspec(pathspec: &str) -> Cow<'_, str> {
2330    if argv_pathspec_preserves_spelling(pathspec) {
2331        Cow::Borrowed(pathspec)
2332    } else {
2333        precompose_utf8_path(pathspec)
2334    }
2335}
2336
2337/// Resolve a pathspec string to a path relative to the repository work tree.
2338///
2339/// `prefix` is the current directory relative to the work tree (no trailing slash),
2340/// or `None` when cwd is the work tree root.
2341#[must_use]
2342pub(crate) fn resolve_pathspec(pathspec: &str, work_tree: &Path, prefix: Option<&str>) -> String {
2343    resolve_pathspec_with_argv_precompose(pathspec, work_tree, prefix, false)
2344}
2345
2346/// Like `resolve_pathspec`, but NFC-normalizes literal argv path segments when requested.
2347#[must_use]
2348pub fn resolve_pathspec_with_argv_precompose(
2349    pathspec: &str,
2350    work_tree: &Path,
2351    prefix: Option<&str>,
2352    argv_precompose: bool,
2353) -> String {
2354    let pathspec = if argv_precompose {
2355        precompose_argv_pathspec(pathspec)
2356    } else {
2357        Cow::Borrowed(pathspec)
2358    };
2359    resolve_pathspec_impl(pathspec.as_ref(), work_tree, prefix)
2360}
2361
2362fn resolve_pathspec_impl(pathspec: &str, work_tree: &Path, prefix: Option<&str>) -> String {
2363    let pathspec = normalize_pathspec_separators(pathspec);
2364    let pathspec = pathspec.as_ref();
2365    // Git: `.` at repo root means "match the whole tree" (not an empty pathspec).
2366    // An empty resolved pathspec would match nothing and breaks `grep -- . t` max-depth.
2367    if pathspec == "." {
2368        return match prefix {
2369            Some(p) if !p.is_empty() => p.to_owned(),
2370            _ => ".".to_owned(),
2371        };
2372    }
2373    if pathspec.contains("../") || pathspec.starts_with("../") {
2374        let cwd = match prefix.filter(|p| !p.is_empty()) {
2375            Some(p) => work_tree.join(p),
2376            None => work_tree.to_path_buf(),
2377        };
2378        let abs = cwd.join(pathspec);
2379        let mut parts: Vec<std::ffi::OsString> = Vec::new();
2380        for component in abs.components() {
2381            use std::path::Component;
2382            match component {
2383                Component::ParentDir => {
2384                    parts.pop();
2385                }
2386                Component::CurDir => {}
2387                other => parts.push(other.as_os_str().to_os_string()),
2388            }
2389        }
2390        let abs_norm: PathBuf = parts.iter().collect();
2391        if let Some(rel) = crate::git_path::strip_worktree_prefix(&abs_norm, work_tree) {
2392            return rel;
2393        }
2394    }
2395    if Path::new(pathspec).is_absolute() {
2396        let abs = Path::new(pathspec);
2397        if let Some(rel) = crate::git_path::strip_worktree_prefix(abs, work_tree) {
2398            return rel;
2399        }
2400        return pathspec.to_owned();
2401    }
2402
2403    if pathspec.starts_with(':') {
2404        if let Some(p) = prefix {
2405            if !p.is_empty() && !literal_pathspecs_enabled() {
2406                let cwd_ps = format!("{}/", p.trim_end_matches('/'));
2407                if pathspec.starts_with(":(") {
2408                    if pathspec_has_top(pathspec) {
2409                        return pathspec.to_owned();
2410                    }
2411                    if let Some(resolved) = resolve_magic_pathspec(pathspec, &cwd_ps) {
2412                        return resolved;
2413                    }
2414                    return pathspec.to_owned();
2415                }
2416                if pathspec_is_exclude(pathspec) {
2417                    if let Some(fixed) = prepend_cwd_to_short_exclude_pathspec(pathspec, p) {
2418                        return fixed;
2419                    }
2420                }
2421            }
2422        }
2423        if let Some(rest) = pathspec.strip_prefix(":/") {
2424            // `:/!foo` / `:/^bar` — `:/` is `:(top)`; the tail is still short magic, not a literal path.
2425            if rest.starts_with('!') || rest.starts_with('^') {
2426                return pathspec.to_owned();
2427            }
2428            return rest.to_owned();
2429        }
2430        // Long magic `:(...)` must stay intact — `:(exclude)path` is not the same as `path`
2431        // (t6132-pathspec-exclude, grep --untracked with exclude pathspecs).
2432        if pathspec.starts_with(":(") {
2433            return pathspec.to_owned();
2434        }
2435        return pathspec.to_owned();
2436    }
2437
2438    match prefix {
2439        Some(p) if !p.is_empty() => {
2440            normalize_relative_path_str(&PathBuf::from(p).join(pathspec).to_string_lossy())
2441        }
2442        _ => normalize_repo_root_relative_pathspec(pathspec),
2443    }
2444}
2445
2446/// Resolve a pathspec and ensure it lies inside `work_tree` (used by `git add`, etc.).
2447///
2448/// Returns `PathOutsideRepository` when resolution stays absolute, matching Git's
2449/// `'%s' is outside repository at '%s'` fatal (t7010).
2450pub fn resolve_pathspec_in_worktree(
2451    elt: &str,
2452    pathspec: &str,
2453    work_tree: &Path,
2454    prefix: Option<&str>,
2455) -> Result<String, PathOutsideRepository> {
2456    resolve_pathspec_in_worktree_with_argv_precompose(elt, pathspec, work_tree, prefix, false)
2457}
2458
2459/// Like [`resolve_pathspec_in_worktree`], NFC-normalizing `pathspec` when `argv_precompose` is set.
2460///
2461/// Callers holding a [`Repository`] should use [`Repository::resolve_worktree_pathspec`], which
2462/// computes `argv_precompose` once per handle instead of reloading config per pathspec.
2463pub(crate) fn resolve_pathspec_in_worktree_with_argv_precompose(
2464    elt: &str,
2465    pathspec: &str,
2466    work_tree: &Path,
2467    prefix: Option<&str>,
2468    argv_precompose: bool,
2469) -> Result<String, PathOutsideRepository> {
2470    let resolved =
2471        resolve_pathspec_with_argv_precompose(pathspec, work_tree, prefix, argv_precompose);
2472    if Path::new(&resolved).is_absolute() {
2473        let wt = work_tree
2474            .canonicalize_simplified()
2475            .unwrap_or_else(|_| work_tree.to_path_buf());
2476        return Err(PathOutsideRepository {
2477            elt: elt.to_string(),
2478            path: resolved,
2479            work_tree: wt,
2480        });
2481    }
2482    Ok(resolved)
2483}
2484
2485/// Normalize a worktree file path for porcelain commands (`blame`, `log`, …).
2486///
2487/// Accepts repo-relative or absolute paths under `work_tree`.
2488#[must_use]
2489#[allow(dead_code)] // integration / re-export use only
2490pub(crate) fn normalize_worktree_file_path(
2491    file_path: &str,
2492    work_tree: &Path,
2493    prefix: Option<&str>,
2494) -> String {
2495    normalize_worktree_file_path_with_git_dir(file_path, work_tree, prefix, None)
2496}
2497
2498/// Like [`normalize_worktree_file_path`], honoring `core.precomposeunicode` argv normalization for `git_dir`.
2499#[must_use]
2500pub(crate) fn normalize_worktree_file_path_with_git_dir(
2501    file_path: &str,
2502    work_tree: &Path,
2503    prefix: Option<&str>,
2504    git_dir: Option<&Path>,
2505) -> String {
2506    let argv_precompose = argv_precompose_enabled(git_dir);
2507    let resolved =
2508        resolve_pathspec_with_argv_precompose(file_path, work_tree, prefix, argv_precompose);
2509    if Path::new(&resolved).is_absolute() {
2510        file_path.to_string()
2511    } else {
2512        resolved
2513    }
2514}
2515
2516#[cfg(test)]
2517mod tree_entry_pathspec_tests {
2518    use super::*;
2519
2520    #[test]
2521    fn t6130_bracket_filename_matches_pathspec() {
2522        assert!(matches_pathspec("f[o][o]", "f[o][o]"));
2523        assert!(matches_pathspec(":(glob)f[o][o]", "f[o][o]"));
2524    }
2525
2526    #[test]
2527    fn literal_prefix_and_exact() {
2528        assert!(matches_pathspec("path1", "path1/file1"));
2529        assert!(matches_pathspec_with_context(
2530            "path1/",
2531            "path1/file1",
2532            PathspecMatchContext::default()
2533        ));
2534        assert!(matches_pathspec("file0", "file0"));
2535        assert!(!matches_pathspec("path", "path1/file1"));
2536    }
2537
2538    #[test]
2539    fn dir_prefix_needs_slash_boundary() {
2540        assert!(matches_pathspec("a/b", "a/b/c"));
2541        assert!(!matches_pathspec("a/b", "a/bc"));
2542        assert!(!matches_pathspec("a/b", "a"));
2543        assert!(matches_pathspec(":(literal)dir", "dir/x"));
2544        assert!(!matches_pathspec(":(literal)dir", "dirx"));
2545        assert!(matches_pathspec(":(icase)DIR", "dir/x"));
2546        assert!(!matches_pathspec(":(icase)DIR", "dirx"));
2547        assert!(matches_pathspec("dir/", "dir/x"));
2548        assert!(!matches_pathspec("dir/", "dir"));
2549    }
2550
2551    #[test]
2552    fn sorted_lookup_agrees_with_linear_scan() {
2553        let mut paths: Vec<&str> = vec![
2554            "a",
2555            "a-b",
2556            "a.txt",
2557            "a/b",
2558            "a/b/c.txt",
2559            "a/bc",
2560            "a0",
2561            "b/x[1].txt",
2562            "dir/sub/file",
2563            "dir-sub",
2564            "f[o][o]",
2565            "z",
2566            "caf\u{e9}/menu",
2567            "cafe\u{301}/old",
2568            "\u{212a}.txt",
2569        ];
2570        paths.sort_unstable();
2571        let specs = [
2572            "caf\u{e9}",
2573            "cafe\u{301}/",
2574            "cafe\u{301}/old",
2575            "K.txt",
2576            "\u{212a}.txt",
2577            "a",
2578            "a/",
2579            "a/b",
2580            "a/b/",
2581            "a/bc",
2582            "a-",
2583            "a-b",
2584            "a.txt",
2585            "b",
2586            "b/",
2587            "dir",
2588            "dir/sub",
2589            "dir/sub/",
2590            "dir/su",
2591            "dir-sub",
2592            "f[o][o]",
2593            "*.txt",
2594            "a/*",
2595            ":(glob)**/*.txt",
2596            ":(literal)f[o][o]",
2597            ":(icase)A/B",
2598            ":!a",
2599            ".",
2600            "./a/b",
2601            "",
2602            "z",
2603            "zz",
2604            "/",
2605            "a//",
2606        ];
2607        for ctx in [
2608            PathspecMatchContext::default(),
2609            PathspecMatchContext {
2610                precompose_paths: true,
2611                ..PathspecMatchContext::default()
2612            },
2613            PathspecMatchContext {
2614                is_directory: true,
2615                ..PathspecMatchContext::default()
2616            },
2617        ] {
2618            for spec in specs {
2619                let list = [spec.to_owned()];
2620                let linear = paths
2621                    .iter()
2622                    .any(|p| matches_pathspec_list_with_context(p, &list, ctx));
2623                assert_eq!(
2624                    sorted_paths_match_pathspec(spec, &paths, ctx),
2625                    linear,
2626                    "spec {spec:?} ctx {ctx:?}"
2627                );
2628            }
2629        }
2630        let empty: [&str; 0] = [];
2631        assert!(!sorted_paths_match_pathspec(
2632            "a",
2633            &empty,
2634            PathspecMatchContext::default()
2635        ));
2636    }
2637
2638    #[test]
2639    fn list_matcher_agrees_with_linear_matching() {
2640        let paths = [
2641            "a",
2642            "a-b",
2643            "a.txt",
2644            "a/",
2645            "a/b",
2646            "a/b/c.txt",
2647            "a/bc",
2648            "a//x",
2649            "a0",
2650            "b/x[1].txt",
2651            "dir/sub/file",
2652            "dir-sub",
2653            "f[o][o]",
2654            "z",
2655            "zz/a",
2656            "é/x",
2657            "e\u{301}/y",
2658            "\u{212a}.txt",
2659            "K.txt",
2660            "",
2661        ];
2662        let lists: &[&[&str]] = &[
2663            &[],
2664            &["a"],
2665            &["a/"],
2666            &["a//"],
2667            &["a/b", "dir/sub/", "z"],
2668            &["a/b/", "a/bc", "dir", "zz"],
2669            &["dir/su", "dir-sub", "f[o][o]"],
2670            &["é", "a-"],
2671            &["e\u{301}", "z"],
2672            &["e\u{301}/y", "K.txt"],
2673            &["\u{212a}.txt"],
2674            &["a", "*.txt"],
2675            &["dir", ":!dir/sub"],
2676            &[":!a"],
2677            &["./a", "z"],
2678            &[".", "a"],
2679            &["", "a"],
2680            &[":(icase)A/B", "z"],
2681        ];
2682        for ctx in [
2683            PathspecMatchContext::default(),
2684            PathspecMatchContext {
2685                precompose_paths: true,
2686                ..PathspecMatchContext::default()
2687            },
2688            PathspecMatchContext {
2689                is_directory: true,
2690                ..PathspecMatchContext::default()
2691            },
2692        ] {
2693            for list in lists {
2694                let specs: Vec<String> = list.iter().map(|s| (*s).to_owned()).collect();
2695                let matcher = PathspecListMatcher::new(&specs, ctx);
2696                for path in paths {
2697                    assert_eq!(
2698                        matcher.matches(path),
2699                        matches_pathspec_list_with_context(path, &specs, ctx),
2700                        "path {path:?} specs {specs:?} ctx {ctx:?}"
2701                    );
2702                }
2703            }
2704        }
2705    }
2706
2707    #[test]
2708    fn list_matcher_uses_literal_lookup_only_for_plain_lists() {
2709        let ctx = PathspecMatchContext::default();
2710        let plain = vec!["a/b".to_owned(), "dir/".to_owned()];
2711        assert!(PathspecListMatcher::new(&plain, ctx).literals.is_some());
2712        let glob = vec!["a/b".to_owned(), "*.txt".to_owned()];
2713        assert!(PathspecListMatcher::new(&glob, ctx).literals.is_none());
2714        let exclude = vec!["a/b".to_owned(), ":!a/b/c".to_owned()];
2715        assert!(PathspecListMatcher::new(&exclude, ctx).literals.is_none());
2716        assert!(PathspecListMatcher::new(&[], ctx).literals.is_none());
2717    }
2718
2719    #[test]
2720    fn precomposed_literal_specs_keep_fast_lookups() {
2721        let ctx = PathspecMatchContext {
2722            precompose_paths: true,
2723            ..PathspecMatchContext::default()
2724        };
2725        let specs: Vec<String> = (0..100).map(|i| format!("d{i}/file.txt")).collect();
2726        let matcher = PathspecListMatcher::new(&specs, ctx);
2727        assert!(
2728            matcher.literals.is_some(),
2729            "core.precomposeunicode must not force a per-spec scan of every path"
2730        );
2731        assert!(matcher.matches("d42/file.txt"));
2732        assert!(!matcher.matches("d42/other.txt"));
2733        let nfd = vec!["cafe\u{301}".to_owned()];
2734        let matcher = PathspecListMatcher::new(&nfd, ctx);
2735        assert!(matcher.literals.is_some());
2736        assert!(matcher.matches("caf\u{e9}/menu"));
2737        assert!(matcher.matches("cafe\u{301}/menu"));
2738        assert!(exact_literal_pathspec_set(&specs, ctx).is_none());
2739    }
2740
2741    #[test]
2742    fn ls_tree_bracket_in_name_is_literal_prefix() {
2743        assert!(matches_ls_tree_pathspec(
2744            "a[a]",
2745            "a[a]/three",
2746            0o100644,
2747            &[]
2748        ));
2749        assert!(!matches_pathspec_with_context(
2750            "a[a]",
2751            "a[a]/three",
2752            PathspecMatchContext::default()
2753        ));
2754    }
2755
2756    #[test]
2757    fn wildcards_cross_slash_by_default() {
2758        assert!(matches_pathspec("f*", "file0"));
2759        assert!(matches_pathspec("*file1", "path1/file1"));
2760        assert!(matches_pathspec_with_context(
2761            "path1/f*",
2762            "path1",
2763            PathspecMatchContext {
2764                is_directory: true,
2765                ..Default::default()
2766            }
2767        ));
2768        assert!(matches_pathspec("path1/*file1", "path1/file1"));
2769    }
2770
2771    #[test]
2772    fn glob_double_star_txt_at_repo_root() {
2773        assert!(pathspec_matches(":(glob)**/*.txt", "untracked.txt"));
2774        assert!(pathspec_matches(":(glob)**/*.txt", "d/untracked.txt"));
2775    }
2776
2777    #[test]
2778    fn trailing_slash_directory_only() {
2779        assert!(!matches_pathspec_with_context(
2780            "file0/",
2781            "file0",
2782            PathspecMatchContext::default()
2783        ));
2784        assert!(matches_pathspec_with_context(
2785            "file0/",
2786            "file0",
2787            PathspecMatchContext {
2788                is_directory: true,
2789                ..Default::default()
2790            }
2791        ));
2792        assert!(matches_pathspec_with_context(
2793            "submod/",
2794            "submod",
2795            PathspecMatchContext {
2796                is_git_submodule: true,
2797                ..Default::default()
2798            }
2799        ));
2800    }
2801
2802    #[test]
2803    fn exclude_top_short_magic_subtracts_from_positive() {
2804        let specs = vec!["*".to_string(), ":/!sub2".to_string()];
2805        assert!(matches_pathspec_list("sub/file", &specs));
2806        assert!(!matches_pathspec_list("sub2/file", &specs));
2807        assert!(pathspec_exclude_matches(":/!sub2", "sub2/file"));
2808    }
2809}
2810
2811#[cfg(test)]
2812mod exact_literal_pathspec_set_tests {
2813    use super::*;
2814
2815    #[test]
2816    fn exact_literal_pathspec_set_accepts_file_paths() {
2817        let ctx = PathspecMatchContext::default();
2818        let set = exact_literal_pathspec_set(&["large.txt".to_string()], ctx).expect("set");
2819        assert_eq!(set.len(), 1);
2820        assert!(set.contains("large.txt"));
2821    }
2822
2823    #[test]
2824    fn exact_literal_pathspec_set_rejects_directory_trailing_slash() {
2825        let ctx = PathspecMatchContext::default();
2826        assert!(exact_literal_pathspec_set(&["src/".to_string()], ctx).is_none());
2827    }
2828
2829    #[test]
2830    fn exact_literal_pathspec_set_rejects_glob_magic() {
2831        let ctx = PathspecMatchContext::default();
2832        assert!(exact_literal_pathspec_set(&[":(glob)*.rs".to_string()], ctx).is_none());
2833    }
2834}
2835
2836#[cfg(test)]
2837mod pathspec_list_tests {
2838    use super::*;
2839    use crate::crlf::parse_gitattributes_content;
2840
2841    #[test]
2842    fn exclude_removes_paths_matching_icase_positive() {
2843        let specs = vec![
2844            ":(icase)*.txt".to_string(),
2845            ":(exclude)submodule/subsub/*".to_string(),
2846        ];
2847        assert!(path_allowed_by_pathspec_list(&specs, "submodule/g.txt"));
2848        assert!(!path_allowed_by_pathspec_list(
2849            &specs,
2850            "submodule/subsub/e.txt"
2851        ));
2852    }
2853
2854    #[test]
2855    fn prefixed_attr_exclude_removes_matching_child_path() {
2856        let specs = vec![
2857            "sub".to_string(),
2858            ":(exclude,attr:labelB,prefix:sub/)".to_string(),
2859        ];
2860        let exclude_only = vec![":(exclude,attr:labelB,prefix:sub/)".to_string()];
2861        let attrs = parse_gitattributes_content("fileB labelB\n");
2862        assert!(!matches_pathspec_list_for_object(
2863            "sub/fileB",
2864            0o100644,
2865            &attrs,
2866            &specs,
2867        ));
2868        assert!(!matches_pathspec_list_for_object(
2869            "sub/fileB",
2870            0o100644,
2871            &attrs,
2872            &exclude_only,
2873        ));
2874    }
2875}
2876
2877#[cfg(test)]
2878mod resolve_pathspec_tests {
2879    use super::*;
2880
2881    fn wt() -> PathBuf {
2882        PathBuf::from("/tmp/grit-pathspec-worktree")
2883    }
2884
2885    #[test]
2886    fn strips_leading_dot_slash_at_repo_root() {
2887        let wt = wt();
2888        assert_eq!(resolve_pathspec("./-", &wt, None), "-");
2889        assert_eq!(resolve_pathspec("./a/b", &wt, None), "a/b");
2890        assert_eq!(resolve_pathspec("././a", &wt, None), "a");
2891        assert_eq!(resolve_pathspec(".//a", &wt, None), "a");
2892        assert_eq!(resolve_pathspec("./", &wt, None), ".");
2893        assert_eq!(resolve_pathspec(".", &wt, None), ".");
2894    }
2895
2896    #[test]
2897    fn prefixed_cwd_resolution_unchanged() {
2898        let wt = wt();
2899        assert_eq!(
2900            resolve_pathspec("file", &wt, Some("sub/dir")),
2901            "sub/dir/file"
2902        );
2903        assert_eq!(resolve_pathspec(":(glob)*.rs", &wt, None), ":(glob)*.rs");
2904    }
2905
2906    #[test]
2907    fn dot_slash_colon_filename_stays_literal_not_exclude_magic() {
2908        let wt = wt();
2909        let resolved = resolve_pathspec("./:!foo", &wt, None);
2910        assert_eq!(resolved, ":(literal):!foo");
2911        assert!(!pathspec_is_exclude(&resolved));
2912        assert!(pathspec_matches(&resolved, ":!foo"));
2913        assert!(!pathspec_matches(&resolved, "other"));
2914    }
2915
2916    #[test]
2917    fn repository_path_as_literal_pathspec_matches_exact_object_path() {
2918        let magic = ":(literal)foo";
2919        let spec = super::repository_path_as_literal_pathspec(magic);
2920        assert!(matches_pathspec_list(magic, std::slice::from_ref(&spec)));
2921        assert!(!matches_pathspec_list("foo", std::slice::from_ref(&spec)));
2922    }
2923
2924    #[test]
2925    fn resolve_absolute_path_under_worktree() {
2926        let tmp = tempfile::tempdir().expect("tempdir");
2927        let wt = tmp.path().join("repo");
2928        std::fs::create_dir_all(wt.join("d")).expect("mkdir");
2929        let file = wt.join("d/b.txt");
2930        std::fs::write(&file, b"x").expect("write");
2931        let wt = wt.canonicalize_simplified().expect("canonicalize");
2932        let abs = file.canonicalize_simplified().expect("canonicalize file");
2933        let spec = abs.to_string_lossy();
2934        assert_eq!(
2935            resolve_pathspec(&spec, &wt, None),
2936            "d/b.txt",
2937            "absolute forward-slash pathspec"
2938        );
2939    }
2940
2941    #[cfg(windows)]
2942    mod windows {
2943        use super::*;
2944
2945        #[test]
2946        fn resolve_backslash_relative_and_absolute_pathspecs() {
2947            let tmp = tempfile::tempdir().expect("tempdir");
2948            let wt = tmp.path().join("repo");
2949            std::fs::create_dir_all(wt.join("d")).expect("mkdir");
2950            let file_a = wt.join("d/a.txt");
2951            let file_b = wt.join("d/b.txt");
2952            std::fs::write(&file_a, b"a").expect("write");
2953            std::fs::write(&file_b, b"b").expect("write");
2954            let wt = wt.canonicalize_simplified().expect("canonicalize worktree");
2955
2956            assert_eq!(
2957                resolve_pathspec(r"d\a.txt", &wt, None),
2958                "d/a.txt",
2959                "backslash relative pathspec (issue #915 A)"
2960            );
2961
2962            let abs_back = file_b.canonicalize_simplified().expect("canonicalize");
2963            let abs_back_spec = abs_back.display().to_string();
2964            assert_eq!(
2965                resolve_pathspec(&abs_back_spec, &wt, None),
2966                "d/b.txt",
2967                "absolute backslash pathspec (issue #915 B)"
2968            );
2969
2970            let abs_forward = abs_back_spec.replace('\\', "/");
2971            assert_eq!(
2972                resolve_pathspec(&abs_forward, &wt, None),
2973                "d/b.txt",
2974                "absolute forward-slash pathspec (issue #915 C)"
2975            );
2976        }
2977    }
2978
2979    #[test]
2980    fn nfd_pathspec_matches_nfc_index_path_when_precompose_enabled() {
2981        let nfc_path = "caf\u{00e9}.txt";
2982        let nfd_spec = format!("cafe\u{0301}.txt");
2983        assert!(!matches_pathspec_list(&nfc_path, &[nfd_spec.clone()]));
2984        assert!(matches_pathspec_list_precompose(
2985            nfc_path,
2986            &[nfd_spec.clone()],
2987            true
2988        ));
2989    }
2990
2991    #[test]
2992    fn argv_precompose_normalizes_nfd_literal_pathspec() {
2993        let nfd = format!("cafe\u{0301}.txt");
2994        let nfc = precompose_argv_pathspec(&nfd);
2995        assert_eq!(nfc.as_ref(), "caf\u{00e9}.txt");
2996        assert!(argv_pathspec_preserves_spelling(":(glob)*.rs"));
2997        assert_eq!(
2998            precompose_argv_pathspec(":(glob)*.rs").as_ref(),
2999            ":(glob)*.rs"
3000        );
3001    }
3002}
3003
3004#[cfg(test)]
3005mod resolved_pathspecs_tests {
3006    use super::*;
3007    use crate::repo::{InitOptions, Repository};
3008
3009    fn repo_with_tree() -> (tempfile::TempDir, Repository) {
3010        let dir = tempfile::tempdir().expect("tempdir");
3011        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3012        std::fs::write(dir.path().join("top.txt"), b"a").expect("write");
3013        std::fs::create_dir_all(dir.path().join("sub")).expect("mkdir");
3014        std::fs::write(dir.path().join("sub/inner.txt"), b"b").expect("write");
3015        (dir, repo)
3016    }
3017
3018    #[test]
3019    fn resolved_pathspecs_subdir_prefix() {
3020        let (dir, repo) = repo_with_tree();
3021        let sub = dir.path().join("sub");
3022        let resolved =
3023            ResolvedPathspecs::resolve(&repo, &sub, &["inner.txt".to_string()], None).expect("ok");
3024        assert_eq!(resolved.specs, ["sub/inner.txt"]);
3025        assert!(resolved.scoped);
3026    }
3027
3028    #[test]
3029    fn resolved_pathspecs_top_level_magic() {
3030        let (dir, repo) = repo_with_tree();
3031        let resolved =
3032            ResolvedPathspecs::resolve(&repo, dir.path(), &[":/top.txt".to_string()], None)
3033                .expect("ok");
3034        assert_eq!(resolved.specs, ["top.txt"]);
3035    }
3036
3037    #[test]
3038    fn resolved_pathspecs_parent_outside_repo() {
3039        let (dir, repo) = repo_with_tree();
3040        let outside = dir.path().parent().expect("parent").join("outside.txt");
3041        std::fs::write(&outside, b"x").expect("write outside");
3042        let spec = outside.to_string_lossy().into_owned();
3043        let err =
3044            ResolvedPathspecs::resolve(&repo, dir.path(), &[spec], None).expect_err("outside");
3045        assert!(matches!(
3046            err,
3047            PathspecResolveError::OutsideRepository { .. }
3048        ));
3049    }
3050
3051    #[test]
3052    fn resolved_pathspecs_empty_argv() {
3053        let (dir, repo) = repo_with_tree();
3054        let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[String::new()], None)
3055            .expect_err("empty");
3056        assert_eq!(err, PathspecResolveError::EmptyPathspec);
3057    }
3058
3059    #[test]
3060    fn resolved_pathspecs_from_file_lf_and_quoted() {
3061        let (dir, repo) = repo_with_tree();
3062        let input = PathspecInput::new(b"top.txt\n\"sub/inner.txt\"\n".to_vec(), false);
3063        let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
3064        assert_eq!(resolved.specs, ["top.txt", "sub/inner.txt"]);
3065    }
3066
3067    #[test]
3068    fn resolved_pathspecs_from_file_nul() {
3069        let (dir, repo) = repo_with_tree();
3070        let input = PathspecInput::new(b"top.txt\0sub/\0".to_vec(), true);
3071        let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
3072        assert_eq!(resolved.specs, ["top.txt", "sub/"]);
3073    }
3074
3075    #[test]
3076    fn resolved_pathspecs_from_file_nul_preserves_literal_bytes() {
3077        let (dir, repo) = repo_with_tree();
3078        std::fs::write(dir.path().join("  spaced  "), b"x").expect("write");
3079        let quoted_dir = dir.path().join("\"double\"");
3080        std::fs::create_dir_all(&quoted_dir).expect("mkdir");
3081        std::fs::write(quoted_dir.join("f"), b"y").expect("write");
3082        let input = PathspecInput::new(b"  spaced  \0\"double\"\0".to_vec(), true);
3083        let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
3084        assert_eq!(resolved.sources, ["  spaced  ", "\"double\""]);
3085    }
3086
3087    #[test]
3088    fn resolved_pathspecs_from_file_blank_line_errors() {
3089        let (dir, repo) = repo_with_tree();
3090        let input = PathspecInput::new(b"\n".to_vec(), false);
3091        let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
3092        assert_eq!(err, PathspecResolveError::EmptyPathspec);
3093    }
3094
3095    #[test]
3096    fn resolved_pathspecs_from_file_nul_empty_record_errors() {
3097        let (dir, repo) = repo_with_tree();
3098        let input = PathspecInput::new(b"a\0\0b".to_vec(), true);
3099        let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
3100        assert_eq!(err, PathspecResolveError::EmptyPathspec);
3101    }
3102
3103    #[test]
3104    fn resolved_pathspecs_from_file_invalid_quote() {
3105        let (dir, repo) = repo_with_tree();
3106        let input = PathspecInput::new(b"\"bad\\z\"\n".to_vec(), false);
3107        let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
3108        assert!(matches!(
3109            err,
3110            PathspecResolveError::FromFile(
3111                PathspecFromFileParseError::InvalidEscapeSequence { .. }
3112            )
3113        ));
3114        let input = PathspecInput::new(b"\"unterminated\n".to_vec(), false);
3115        let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
3116        assert!(matches!(
3117            err,
3118            PathspecResolveError::FromFile(PathspecFromFileParseError::InvalidCStyleQuoting { .. })
3119        ));
3120    }
3121
3122    #[test]
3123    fn resolved_pathspecs_entire_tree_when_unscoped() {
3124        let (dir, repo) = repo_with_tree();
3125        let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], None).expect("ok");
3126        assert!(resolved.is_entire_tree());
3127        assert!(resolved.specs.is_empty());
3128    }
3129}
3130
3131#[cfg(test)]
3132mod resolve_magic_pathspec_tests {
3133    use super::*;
3134
3135    #[test]
3136    fn long_magic_token_predicate() {
3137        assert!(is_long_magic_pathspec_token(":(attr:rust)"));
3138        assert!(!is_long_magic_pathspec_token(":0:file"));
3139        assert!(!is_long_magic_pathspec_token("main"));
3140    }
3141
3142    #[test]
3143    fn top_magic_does_not_inject_cwd_prefix() {
3144        assert_eq!(
3145            resolve_magic_pathspec(":(top)a/g.c", "a/b/").expect("magic"),
3146            ":(top)a/g.c"
3147        );
3148        assert_eq!(
3149            resolve_magic_pathspec(":(top,glob)a/**/*.c", "a/b/").expect("magic"),
3150            ":(top,glob)a/**/*.c"
3151        );
3152        assert_eq!(
3153            resolve_magic_pathspec(":(exclude,top)a/b", "a/b/").expect("magic"),
3154            ":(exclude,top)a/b"
3155        );
3156    }
3157
3158    #[test]
3159    fn non_top_magic_still_injects_prefix_from_subdirectory() {
3160        assert_eq!(
3161            resolve_magic_pathspec(":(glob)**/f.txt", "sub/").expect("magic"),
3162            ":(glob,prefix:sub/)**/f.txt"
3163        );
3164        assert_eq!(
3165            resolve_magic_pathspec(":(exclude)vendor", "sub/").expect("magic"),
3166            ":(exclude,prefix:sub/)vendor"
3167        );
3168    }
3169
3170    #[test]
3171    fn resolve_pathspec_top_from_subdirectory() {
3172        let dir = tempfile::tempdir().expect("tempdir");
3173        let wt = dir.path();
3174        std::fs::create_dir_all(wt.join("a/b")).expect("mkdir");
3175        assert_eq!(
3176            resolve_pathspec(":(top)a/g.c", wt, Some("a/b")),
3177            ":(top)a/g.c"
3178        );
3179    }
3180}
3181
3182#[cfg(test)]
3183mod rev_walk_pathspec_resolution_tests {
3184    use super::*;
3185    use crate::config::environment::{Environment, RepositoryOptions};
3186    use crate::repo::{InitOptions, Repository};
3187
3188    fn repo_from_sub(worktree: &std::path::Path, sub: &std::path::Path) -> Repository {
3189        let mut env = Environment::empty();
3190        env.cwd = sub.to_path_buf();
3191        let mut opts = RepositoryOptions::empty();
3192        opts.environment = env;
3193        Repository::discover(Some(worktree), &opts).expect("discover")
3194    }
3195
3196    #[test]
3197    fn glob_magic_from_subdirectory_gets_prefix_and_matches() {
3198        let dir = tempfile::tempdir().expect("tempdir");
3199        let wt = dir.path();
3200        std::fs::create_dir_all(wt.join("sub")).expect("mkdir");
3201        std::fs::write(wt.join("sub/f.txt"), b"x").expect("write");
3202        Repository::init(wt, &InitOptions::default()).expect("init");
3203        let repo = repo_from_sub(wt, &wt.join("sub"));
3204        let specs = repo
3205            .resolve_rev_walk_pathspecs(&[":(glob)**/f.txt".to_owned()])
3206            .expect("resolve");
3207        assert_eq!(specs, [":(glob,prefix:sub/)**/f.txt"]);
3208        assert!(pathspec_matches(&specs[0], "sub/f.txt"));
3209        assert!(!pathspec_matches(&specs[0], "other/f.txt"));
3210    }
3211
3212    #[test]
3213    fn short_exclude_from_subdirectory_uses_prefix_magic() {
3214        let dir = tempfile::tempdir().expect("tempdir");
3215        let wt = dir.path();
3216        std::fs::create_dir_all(wt.join("sub")).expect("mkdir");
3217        Repository::init(wt, &InitOptions::default()).expect("init");
3218        let repo = repo_from_sub(wt, &wt.join("sub"));
3219        let specs = repo
3220            .resolve_rev_walk_pathspecs(&[":!f.txt".to_owned()])
3221            .expect("resolve");
3222        assert_eq!(specs, [".", ":(exclude,prefix:sub/)f.txt"]);
3223        assert!(matches_pathspec_list("other/g.txt", &specs));
3224        assert!(!matches_pathspec_list("sub/f.txt", &specs));
3225    }
3226
3227    #[test]
3228    fn glob_with_prefix_descends_into_cwd_directory() {
3229        let spec = ":(glob,prefix:sub/)**/f.txt";
3230        assert!(pathspec_wants_descent_into_tree(spec, "sub"));
3231        assert!(pathspec_entry_is_relevant(
3232            "sub/f.txt",
3233            false,
3234            &[spec.to_owned()]
3235        ));
3236    }
3237
3238    #[test]
3239    fn top_magic_from_subdirectory_stays_unprefixed() {
3240        let dir = tempfile::tempdir().expect("tempdir");
3241        let wt = dir.path();
3242        std::fs::create_dir_all(wt.join("a/b")).expect("mkdir");
3243        Repository::init(wt, &InitOptions::default()).expect("init");
3244        let repo = repo_from_sub(wt, &wt.join("a/b"));
3245        let specs = repo
3246            .resolve_rev_walk_pathspecs(&[":(top)a/g.c".to_owned()])
3247            .expect("resolve");
3248        assert_eq!(specs, [":(top)a/g.c"]);
3249        assert!(pathspec_matches(&specs[0], "a/g.c"));
3250        assert!(!pathspec_matches(&specs[0], "a/b/h.c"));
3251    }
3252}