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 {
681        pattern.to_string()
682    };
683    let pattern = combined.as_str();
684    let mut len = simple_length(pattern);
685    if len != pattern.len() {
686        while len > 0 && pattern.as_bytes()[len - 1] != b'/' {
687            len -= 1;
688        }
689    }
690    while len > 0 && pattern.as_bytes()[len - 1] == b'/' {
691        len -= 1;
692    }
693    if len == 0 {
694        return None;
695    }
696    Some(combined[..len].to_string())
697}
698
699/// Bloom filter lookup prefix for a pathspec at the repository root.
700#[must_use]
701pub fn bloom_lookup_prefix(spec: &str) -> Option<String> {
702    bloom_lookup_prefix_with_cwd(spec, None)
703}
704
705/// Whether every pathspec can participate in Bloom precomputation (Git `forbid_bloom_filters`).
706#[must_use]
707pub fn pathspecs_allow_bloom(specs: &[String]) -> bool {
708    specs.iter().all(|s| {
709        !s.is_empty() && !pathspec_is_exclude(s) && bloom_lookup_prefix_with_cwd(s, None).is_some()
710    })
711}
712
713/// Whether `path` is included when Git applies a pathspec list with optional `:(exclude)` entries.
714///
715/// A path is rejected if any exclude pathspec matches it. When at least one non-exclude pathspec is
716/// present, the path must also match one of those positives (`OR` semantics).
717#[must_use]
718pub fn path_allowed_by_pathspec_list(specs: &[String], path: &str) -> bool {
719    let mut has_positive = false;
720    let mut positive_match = false;
721    for s in specs {
722        let (elem, raw_pattern) = parse_element_magic(s);
723        let magic = combine_magic(elem);
724        if magic.exclude {
725            if path_matches_pathspec_tail(raw_pattern, path, magic) {
726                return false;
727            }
728            continue;
729        }
730        has_positive = true;
731        if pathspec_matches(s, path) {
732            positive_match = true;
733        }
734    }
735    !has_positive || positive_match
736}
737
738/// True when `spec` matches `path` for pathspec bookkeeping (positive match or exclude hit).
739#[must_use]
740pub fn pathspec_contributes_match(spec: &str, path: &str) -> bool {
741    pathspec_matches(spec, path) || pathspec_exclude_matches(spec, path)
742}
743
744fn path_matches_pathspec_tail(raw_pattern: &str, path: &str, magic: PathspecMagic) -> bool {
745    if magic.literal && magic.glob {
746        return false;
747    }
748    let pattern = strip_top_magic(raw_pattern);
749    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
750        if !path.starts_with(prefix) {
751            return false;
752        }
753        &path[prefix.len()..]
754    } else {
755        path
756    };
757    pathspec_matches_tail(pattern, path_for_match, magic)
758}
759
760/// True if `path` is matched by `spec` (Git pathspec syntax, including magic and globals).
761///
762/// Same as [`matches_pathspec`] (default file context; exclude specs never match positively here).
763/// See [`matches_pathspec_list`].
764#[must_use]
765pub fn pathspec_matches(spec: &str, path: &str) -> bool {
766    matches_pathspec(spec, path)
767}
768
769/// Returns whether `spec` uses Git's exclude magic (`:(exclude)`, `:!`, `:^`, etc.).
770#[must_use]
771pub fn pathspec_is_exclude(spec: &str) -> bool {
772    let (elem_magic, _) = parse_element_magic(spec);
773    combine_magic(elem_magic).exclude
774}
775
776/// Whether a path-limited revision tree walk should recurse into directory `path`.
777///
778/// Uses [`pathspec_wants_descent_into_tree`] for non-exclude specs. Exclude-only lists never
779/// trigger that helper alone, but Git's implicit match-all still requires walking non-excluded
780/// directories so nested paths can match [`matches_pathspec_list`].
781#[must_use]
782pub fn pathspec_wants_descent_for_rev_tree(path: &str, specs: &[String]) -> bool {
783    if specs.is_empty() {
784        return true;
785    }
786    for spec in specs {
787        if !pathspec_is_exclude(spec) && pathspec_wants_descent_into_tree(spec, path) {
788            return true;
789        }
790    }
791    if !specs.iter().any(|s| pathspec_is_exclude(s)) {
792        return false;
793    }
794    let ctx = PathspecMatchContext {
795        is_directory: true,
796        is_git_submodule: false,
797        precompose_paths: false,
798    };
799    for spec in specs {
800        if pathspec_is_exclude(spec)
801            && pathspec_exclude_element_matches_with_context(spec, path, ctx)
802        {
803            return false;
804        }
805    }
806    !specs.iter().any(|s| !pathspec_is_exclude(s))
807}
808
809/// Whether tree-walking should recurse into directory `full_name` for pathspec `spec` without
810/// `-r` (Git `read_tree` / `show_recursive` “interesting” descent).
811///
812/// Exclude-only patterns never trigger descent alone.
813#[must_use]
814pub fn pathspec_wants_descent_into_tree(spec: &str, full_name: &str) -> bool {
815    if pathspec_is_exclude(spec) {
816        return false;
817    }
818    let (elem_magic, raw_pattern) = parse_element_magic(spec);
819    let magic = combine_magic(elem_magic);
820    if magic.exclude {
821        return false;
822    }
823    let pattern = strip_top_magic(raw_pattern);
824    let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
825    if pattern.is_empty() || pattern == "." {
826        return true;
827    }
828    let dir_prefix = format!("{full_name}/");
829    if pattern.starts_with(&dir_prefix) {
830        return true;
831    }
832    let probe = format!("{full_name}/.__grit_ls_tree_probe__");
833    matches_ls_tree_pathspec(spec, &probe, 0o100644, &[])
834}
835
836/// Like [`matches_pathspec_set_for_object`], but uses [`matches_ls_tree_pathspec`] for each
837/// element so `ls-files` / index filtering agrees with `ls-tree` on patterns such as `a[a]`.
838#[must_use]
839pub fn matches_pathspec_set_for_object_ls_tree(
840    specs: &[String],
841    path: &str,
842    mode: u32,
843    attr_rules: &[AttrRule],
844) -> bool {
845    if specs.is_empty() {
846        return true;
847    }
848    let mut positives: Vec<&str> = Vec::new();
849    let mut excludes: Vec<&str> = Vec::new();
850    for s in specs {
851        if pathspec_is_exclude(s) {
852            excludes.push(s.as_str());
853        } else {
854            positives.push(s.as_str());
855        }
856    }
857    let positive_ok = if positives.is_empty() {
858        true
859    } else {
860        positives
861            .iter()
862            .any(|s| matches_ls_tree_pathspec(s, path, mode, attr_rules))
863    };
864    if !positive_ok {
865        return false;
866    }
867    for ex in excludes {
868        if matches_ls_tree_pathspec(ex, path, mode, attr_rules) {
869            return false;
870        }
871    }
872    true
873}
874
875/// True if `path` matches the combined pathspec list: any positive spec (or all paths when there
876/// are only excludes, matching Git `parse_pathspec`), and not matched by any exclude spec.
877#[must_use]
878pub fn matches_pathspec_set_for_object(
879    specs: &[String],
880    path: &str,
881    mode: u32,
882    attr_rules: &[AttrRule],
883) -> bool {
884    if specs.is_empty() {
885        return true;
886    }
887    let mut positives: Vec<&str> = Vec::new();
888    let mut excludes: Vec<&str> = Vec::new();
889    for s in specs {
890        if pathspec_is_exclude(s) {
891            excludes.push(s.as_str());
892        } else {
893            positives.push(s.as_str());
894        }
895    }
896    let positive_ok = if positives.is_empty() {
897        true
898    } else {
899        positives
900            .iter()
901            .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
902    };
903    if !positive_ok {
904        return false;
905    }
906    for ex in excludes {
907        if matches_pathspec_for_object(ex, path, mode, attr_rules) {
908            return false;
909        }
910    }
911    true
912}
913
914/// True if `spec` uses `:(top)` or short `:/` (repo-root-relative) magic.
915#[must_use]
916pub fn pathspec_has_top(spec: &str) -> bool {
917    let (elem_magic, _) = parse_element_magic(spec);
918    combine_magic(elem_magic).top
919}
920
921/// True if `spec` carries an `attr:` clause in long-form `:(...)` magic, so matching it needs
922/// `.gitattributes` rules. Malformed clauses count too; [`validate_attr_pathspecs`] rejects them.
923///
924/// Such specs only match through the attribute-aware entry points:
925/// [`crate::repo::Repository::pathspec_matcher`], which loads the repository's attributes and
926/// validates the clauses, or [`matches_pathspec_list_for_object`] with caller-supplied rules.
927#[must_use]
928pub fn pathspec_has_attr_magic(spec: &str) -> bool {
929    // Attr magic only exists in long form; skip the allocating parse for every other spec.
930    spec.starts_with(":(") && spec.contains("attr:") && !attr_clauses(spec).is_empty()
931}
932
933/// A resolved pathspec list bound to one repository's matching rules, built by
934/// [`crate::repo::Repository::pathspec_matcher`].
935///
936/// Holds the repository's attributes when a spec carries `:(attr:...)` magic, so testing many
937/// paths reads them once. Attr requirements see `[attr]` macro expansion and match attribute
938/// patterns case-insensitively when `core.ignorecase` is set, as Git does. Paths are treated as regular files: a spec ending in `/` does not match
939/// a path equal to its directory, and `builtin_objectmode` reads as `100644`. Use
940/// [`matches_pathspec_list_for_object`] when the entry mode matters.
941#[derive(Debug, Clone)]
942pub struct PathspecMatcher<'s> {
943    specs: Cow<'s, [String]>,
944    precompose: bool,
945    attrs: Option<std::sync::Arc<crate::attributes::ParsedGitAttributes>>,
946    /// Match attribute patterns case-insensitively (`core.ignorecase`).
947    attr_icase: bool,
948}
949
950impl<'s> PathspecMatcher<'s> {
951    pub(crate) fn new(
952        specs: &'s [String],
953        precompose: bool,
954        attrs: Option<std::sync::Arc<crate::attributes::ParsedGitAttributes>>,
955        attr_icase: bool,
956    ) -> Self {
957        // The attribute-aware matcher has no precompose context, so normalize the specs up front.
958        let specs = if precompose && attrs.is_some() {
959            Cow::Owned(
960                specs
961                    .iter()
962                    .map(|spec| precompose_utf8_path(spec).into_owned())
963                    .collect(),
964            )
965        } else {
966            Cow::Borrowed(specs)
967        };
968        Self {
969            specs,
970            precompose,
971            attrs,
972            attr_icase,
973        }
974    }
975
976    /// Whether `path` is selected: it matches at least one positive spec (any path when there
977    /// are none or the list is empty) and no `:(exclude)` spec.
978    #[must_use]
979    pub fn matches(&self, path: &str) -> bool {
980        const REGULAR_FILE: u32 = 0o100644;
981        let Some(attrs) = &self.attrs else {
982            return matches_pathspec_list_with_context(
983                path,
984                &self.specs,
985                pathspec_match_context_with_precompose(self.precompose),
986            );
987        };
988        let path = if self.precompose {
989            precompose_utf8_path(path)
990        } else {
991            Cow::Borrowed(path)
992        };
993        let lookup = AttrLookup::new(&attrs.rules, &attrs.macros, self.attr_icase);
994        matches_pathspec_list_with_attrs(&path, REGULAR_FILE, lookup, &self.specs)
995    }
996
997    /// Whether a changed file is selected, checking its current `path` and, for a rename or
998    /// copy, its `old_path` too (Git limits diffs on either side of a rename).
999    #[must_use]
1000    pub fn matches_change(&self, path: &str, old_path: Option<&str>) -> bool {
1001        self.specs.is_empty()
1002            || self.matches(path)
1003            || old_path.is_some_and(|old| old != path && self.matches(old))
1004    }
1005
1006    /// Whether the list is empty (every path is selected).
1007    #[must_use]
1008    pub fn is_empty(&self) -> bool {
1009        self.specs.is_empty()
1010    }
1011}
1012
1013fn pathspec_match_one_positive(path: &str, magic: PathspecMagic, raw_pattern: &str) -> bool {
1014    if magic.literal && magic.glob {
1015        return false;
1016    }
1017    let pattern = strip_top_magic(raw_pattern);
1018    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1019        if !path.starts_with(prefix) {
1020            return false;
1021        }
1022        &path[prefix.len()..]
1023    } else {
1024        path
1025    };
1026    pathspec_matches_tail(pattern, path_for_match, magic)
1027}
1028
1029/// Attribute state that `:(attr:...)` requirements are evaluated against.
1030#[derive(Clone, Copy)]
1031pub(crate) struct AttrLookup<'a> {
1032    rules: &'a [AttrRule],
1033    /// `[attr]` macro definitions; `None` means no macros are defined.
1034    macros: Option<&'a MacroTable>,
1035    /// Match attribute patterns case-insensitively (`core.ignorecase`).
1036    icase: bool,
1037}
1038
1039impl<'a> AttrLookup<'a> {
1040    pub(crate) fn new(rules: &'a [AttrRule], macros: &'a MacroTable, icase: bool) -> Self {
1041        Self {
1042            rules,
1043            macros: Some(macros),
1044            icase,
1045        }
1046    }
1047
1048    fn rules_only(rules: &'a [AttrRule]) -> Self {
1049        Self {
1050            rules,
1051            macros: None,
1052            icase: false,
1053        }
1054    }
1055}
1056
1057fn attr_requirements_match(
1058    requirements: &[AttrRequirement],
1059    attrs: AttrLookup<'_>,
1060    path: &str,
1061    is_dir: bool,
1062    mode: u32,
1063) -> bool {
1064    let no_macros = MacroTable::default();
1065    let assigned = path_gitattribute_values(
1066        attrs.rules,
1067        attrs.macros.unwrap_or(&no_macros),
1068        attrs.icase,
1069        path,
1070        is_dir,
1071    );
1072    requirements.iter().all(|req| {
1073        let value: Option<Cow<'_, str>> = if req.name() == "builtin_objectmode" {
1074            (mode != 0).then(|| Cow::Owned(format!("{mode:06o}")))
1075        } else {
1076            assigned
1077                .get(req.name())
1078                .map(|v| Cow::Borrowed(v.display()))
1079                .filter(|v| v != "unspecified")
1080        };
1081        match req {
1082            AttrRequirement::Set(_) => value.as_deref() == Some("set"),
1083            AttrRequirement::Unset(_) => value.as_deref() == Some("unset"),
1084            AttrRequirement::Unspecified(_) => value.is_none(),
1085            AttrRequirement::Value(_, expected) => value.as_deref() == Some(expected.as_str()),
1086        }
1087    })
1088}
1089
1090fn matches_pathspec_element_with_context(
1091    spec: &str,
1092    path: &str,
1093    ctx: PathspecMatchContext,
1094) -> bool {
1095    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1096    let magic = combine_magic(elem_magic);
1097    if magic.exclude {
1098        return false;
1099    }
1100    if magic.literal && magic.glob {
1101        return false;
1102    }
1103    if !magic.attr_requirements.is_empty() {
1104        return false;
1105    }
1106    if magic.literal || magic.glob || magic.icase {
1107        return pathspec_matches(spec, path);
1108    }
1109    let pattern = strip_top_magic(raw_pattern);
1110    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1111        if !path.starts_with(prefix) {
1112            return false;
1113        }
1114        &path[prefix.len()..]
1115    } else {
1116        path
1117    };
1118    matches_pathspec_with_context(pattern, path_for_match, ctx)
1119}
1120
1121fn pathspec_exclude_element_matches_with_context(
1122    spec: &str,
1123    path: &str,
1124    ctx: PathspecMatchContext,
1125) -> bool {
1126    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1127    let mut magic = combine_magic(elem_magic);
1128    if !magic.exclude {
1129        return false;
1130    }
1131    magic.exclude = false;
1132    if magic.literal && magic.glob {
1133        return false;
1134    }
1135    if !magic.attr_requirements.is_empty() {
1136        // Attribute pathspecs need `.gitattributes` context; use
1137        // [`matches_pathspec_list_for_object`] for those.
1138        return false;
1139    }
1140    if magic.literal || magic.glob || magic.icase {
1141        return pathspec_match_one_positive(path, magic, raw_pattern);
1142    }
1143    let pattern = strip_top_magic(raw_pattern);
1144    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1145        if !path.starts_with(prefix) {
1146            return false;
1147        }
1148        &path[prefix.len()..]
1149    } else {
1150        path
1151    };
1152    matches_pathspec_with_context(pattern, path_for_match, ctx)
1153}
1154
1155/// True if `path` is matched by an exclude pathspec's pattern. Returns `false` if `spec` is not
1156/// an exclude pathspec.
1157#[must_use]
1158pub fn pathspec_exclude_matches(spec: &str, path: &str) -> bool {
1159    pathspec_exclude_element_matches_with_context(spec, path, PathspecMatchContext::default())
1160}
1161
1162/// When every pathspec is an exclude and none use `:(top)` / `:/`, Git prepends an implicit
1163/// positive that matches only under the process cwd (relative to the work tree), not the whole
1164/// repository (`PATHSPEC_PREFER_CWD` in `pathspec.c`). `cwd_from_repo_root` is that prefix
1165/// without a trailing slash, or empty at the work tree root.
1166#[must_use]
1167pub fn extend_pathspec_list_implicit_cwd(
1168    specs: &[String],
1169    cwd_from_repo_root: Option<&str>,
1170) -> Vec<String> {
1171    if specs.is_empty() {
1172        return specs.to_vec();
1173    }
1174    if !specs.iter().all(|s| pathspec_is_exclude(s)) {
1175        return specs.to_vec();
1176    }
1177    let any_top = specs.iter().any(|s| pathspec_has_top(s));
1178    if any_top {
1179        return specs.to_vec();
1180    }
1181    let Some(cwd) = cwd_from_repo_root.map(str::trim).filter(|s| !s.is_empty()) else {
1182        return specs.to_vec();
1183    };
1184    let cwd = cwd.trim_end_matches('/');
1185    if cwd.is_empty() {
1186        return specs.to_vec();
1187    }
1188    let mut out = Vec::with_capacity(specs.len() + 1);
1189    out.push(format!("{cwd}/"));
1190    out.extend_from_slice(specs);
1191    out
1192}
1193
1194/// Git `match_pathspec` semantics over a pathspec list: OR of positive specs minus OR of exclude
1195/// specs. If every element is exclude-only, Git implicitly prepends `.` (match all); this
1196/// function does the same.
1197///
1198/// No `.gitattributes` rules are available here, so a positive `:(attr:...)` spec never matches
1199/// and an exclude `:(exclude,attr:...)` spec never excludes. Use
1200/// [`crate::repo::Repository::paths_match_pathspecs`] or [`matches_pathspec_list_for_object`]
1201/// when specs may carry attr magic (see [`pathspec_has_attr_magic`]).
1202#[must_use]
1203pub fn matches_pathspec_list(path: &str, specs: &[String]) -> bool {
1204    matches_pathspec_list_with_context(path, specs, PathspecMatchContext::default())
1205}
1206
1207/// Like [`matches_pathspec_list`], but uses `ctx` for non-magic pathspec elements (trailing `/`).
1208///
1209/// Like [`matches_pathspec_list`], this does not evaluate `:(attr:...)` magic: positive attr specs
1210/// never match and attr excludes never exclude.
1211#[must_use]
1212pub fn matches_pathspec_list_with_context(
1213    path: &str,
1214    specs: &[String],
1215    ctx: PathspecMatchContext,
1216) -> bool {
1217    if specs.is_empty() {
1218        return true;
1219    }
1220    let mut has_exclude = false;
1221    let mut has_positive = false;
1222    let mut positive = false;
1223    for spec in specs {
1224        if pathspec_is_exclude(spec) {
1225            has_exclude = true;
1226        } else {
1227            has_positive = true;
1228            positive = positive || matches_pathspec_element_with_context(spec, path, ctx);
1229        }
1230    }
1231    if has_positive && !positive {
1232        return false;
1233    }
1234    if !has_exclude {
1235        return true;
1236    }
1237    let excluded = specs.iter().any(|s| {
1238        pathspec_is_exclude(s) && pathspec_exclude_element_matches_with_context(s, path, ctx)
1239    });
1240    !excluded
1241}
1242
1243/// Whether any of `sorted_paths` matches the single pathspec `spec`.
1244///
1245/// Same result as testing every path with [`matches_pathspec_list_with_context`] and `[spec]`,
1246/// but a plain literal pathspec (no magic, no wildcards, no `./`) is answered with a binary
1247/// search instead of a scan, so checking many pathspecs against a large index stays cheap.
1248///
1249/// `sorted_paths` must be sorted in byte order (the [`str`] ordering) for the result to be
1250/// correct; duplicates are allowed.
1251pub(crate) fn sorted_paths_match_pathspec<S: AsRef<str>>(
1252    spec: &str,
1253    sorted_paths: &[S],
1254    ctx: PathspecMatchContext,
1255) -> bool {
1256    let list = [spec.to_owned()];
1257    let matches_linearly = |p: &S| matches_pathspec_list_with_context(p.as_ref(), &list, ctx);
1258    if !is_plain_literal_pathspec(spec, ctx) {
1259        return sorted_paths.iter().any(matches_linearly);
1260    }
1261    let spec_nfc = literal_spec_for_lookup(spec, ctx);
1262    if sorted_literal_lookup(&spec_nfc, sorted_paths) {
1263        return true;
1264    }
1265    // A hit on the NFC spelling is always a match; a path stored in another normalization form
1266    // can only match through a non-ASCII segment, so a miss rechecks just those paths.
1267    ctx.precompose_paths
1268        && sorted_paths
1269            .iter()
1270            .filter(|p| !p.as_ref().is_ascii())
1271            .any(matches_linearly)
1272}
1273
1274/// Binary-search `sorted_paths` for the plain literal `spec` (exact path or directory prefix).
1275fn sorted_literal_lookup<S: AsRef<str>>(spec: &str, sorted_paths: &[S]) -> bool {
1276    let has_path_with_prefix = |prefix: &str| {
1277        let at = sorted_paths.partition_point(|p| p.as_ref() < prefix);
1278        sorted_paths
1279            .get(at)
1280            .is_some_and(|p| p.as_ref().starts_with(prefix))
1281    };
1282    if spec.ends_with('/') {
1283        return has_path_with_prefix(spec);
1284    }
1285    let at = sorted_paths.partition_point(|p| p.as_ref() < spec);
1286    if sorted_paths.get(at).is_some_and(|p| p.as_ref() == spec) {
1287        return true;
1288    }
1289    has_path_with_prefix(&format!("{spec}/"))
1290}
1291
1292/// The spelling of a plain literal `spec` that [`matches_pathspec_with_context`] compares paths
1293/// against: NFC when the context precomposes paths, else `spec` itself.
1294fn literal_spec_for_lookup(spec: &str, ctx: PathspecMatchContext) -> Cow<'_, str> {
1295    if ctx.precompose_paths {
1296        precompose_utf8_path(spec)
1297    } else {
1298        Cow::Borrowed(spec)
1299    }
1300}
1301
1302/// Repository-relative paths when every spec is a plain literal file path (no trailing `/`).
1303///
1304/// Used to narrow index/worktree diffs to a small path set without scanning the full index.
1305/// Returns `None` when `ctx` precomposes paths: another normalization form of a spec could also
1306/// match, and only a scan of the candidate paths can find it.
1307pub(crate) fn exact_literal_pathspec_set(
1308    specs: &[String],
1309    ctx: PathspecMatchContext,
1310) -> Option<std::collections::BTreeSet<String>> {
1311    if specs.is_empty() || ctx.precompose_paths {
1312        return None;
1313    }
1314    if !specs
1315        .iter()
1316        .all(|s| is_plain_literal_pathspec(s, ctx) && !s.ends_with('/'))
1317    {
1318        return None;
1319    }
1320    Some(specs.iter().cloned().collect())
1321}
1322
1323/// Whether `spec` matches exactly the path `spec` and everything under directory `spec`
1324/// (no magic, no wildcards, no `./`, and no context that changes trailing-`/` handling).
1325///
1326/// With [`PathspecMatchContext::precompose_paths`], "the path `spec`" means equality after NFC
1327/// normalization of both sides; see [`literal_spec_for_lookup`].
1328fn is_plain_literal_pathspec(spec: &str, ctx: PathspecMatchContext) -> bool {
1329    !ctx.is_directory
1330        && !ctx.is_git_submodule
1331        && !spec.is_empty()
1332        && spec != "."
1333        && !spec.starts_with("./")
1334        && !spec.starts_with(':')
1335        && simple_length(spec) == spec.len()
1336        && !(literal_global() || glob_global() || noglob_global() || icase_global())
1337}
1338
1339/// A pathspec list prepared once for matching many paths with the same context.
1340///
1341/// Gives the same answers as [`matches_pathspec_list_with_context`] with the list and context
1342/// it was built from. When every element is a plain literal (see
1343/// [`sorted_paths_match_pathspec`]), a path is answered with hash lookups of the path and its
1344/// leading directories instead of comparing it against every element, which keeps matching a
1345/// large index against many pathspecs linear in the index size.
1346pub(crate) struct PathspecListMatcher<'a> {
1347    specs: &'a [String],
1348    ctx: PathspecMatchContext,
1349    literals: Option<LiteralPathspecSet>,
1350}
1351
1352/// Plain literal pathspecs split by how they match, spelled as [`literal_spec_for_lookup`] does.
1353struct LiteralPathspecSet {
1354    /// Specs without a trailing `/`: they match the path itself.
1355    exact: HashSet<String>,
1356    /// Directories whose contents match: every spec, with one trailing `/` removed.
1357    dirs: HashSet<String>,
1358}
1359
1360impl<'a> PathspecListMatcher<'a> {
1361    /// Prepare `specs` for repeated matching under `ctx`.
1362    pub(crate) fn new(specs: &'a [String], ctx: PathspecMatchContext) -> Self {
1363        let all_plain =
1364            !specs.is_empty() && specs.iter().all(|s| is_plain_literal_pathspec(s, ctx));
1365        let literals = all_plain.then(|| {
1366            let mut set = LiteralPathspecSet {
1367                exact: HashSet::with_capacity(specs.len()),
1368                dirs: HashSet::with_capacity(specs.len()),
1369            };
1370            for spec in specs {
1371                let spec = literal_spec_for_lookup(spec, ctx);
1372                match spec.strip_suffix('/') {
1373                    Some(dir) => {
1374                        set.dirs.insert(dir.to_owned());
1375                    }
1376                    None => {
1377                        set.exact.insert(spec.to_string());
1378                        set.dirs.insert(spec.into_owned());
1379                    }
1380                }
1381            }
1382            set
1383        });
1384        Self {
1385            specs,
1386            ctx,
1387            literals,
1388        }
1389    }
1390
1391    /// Whether `path` matches the pathspec list.
1392    pub(crate) fn matches(&self, path: &str) -> bool {
1393        let Some(set) = &self.literals else {
1394            return matches_pathspec_list_with_context(path, self.specs, self.ctx);
1395        };
1396        let path = literal_spec_for_lookup(path, self.ctx);
1397        let path = path.as_ref();
1398        if set.exact.contains(path) {
1399            return true;
1400        }
1401        path.bytes()
1402            .enumerate()
1403            .any(|(i, b)| b == b'/' && set.dirs.contains(&path[..i]))
1404    }
1405}
1406
1407/// `matches_pathspec_list` for tree/index objects with mode and `.gitattributes` rules.
1408///
1409/// `attr_rules` are applied without `[attr]` macro definitions and with case-sensitive
1410/// patterns; [`crate::repo::Repository::pathspec_matcher`] also honors macros and
1411/// `core.ignorecase`.
1412#[must_use]
1413pub fn matches_pathspec_list_for_object(
1414    path: &str,
1415    mode: u32,
1416    attr_rules: &[AttrRule],
1417    specs: &[String],
1418) -> bool {
1419    matches_pathspec_list_with_attrs(path, mode, AttrLookup::rules_only(attr_rules), specs)
1420}
1421
1422pub(crate) fn matches_pathspec_list_with_attrs(
1423    path: &str,
1424    mode: u32,
1425    attrs: AttrLookup<'_>,
1426    specs: &[String],
1427) -> bool {
1428    if specs.is_empty() {
1429        return true;
1430    }
1431    let mut has_exclude = false;
1432    let mut has_positive = false;
1433    let mut positive = false;
1434    for spec in specs {
1435        if pathspec_is_exclude(spec) {
1436            has_exclude = true;
1437        } else {
1438            has_positive = true;
1439            positive = positive || matches_pathspec_for_object_with_attrs(spec, path, mode, attrs);
1440        }
1441    }
1442    if has_positive && !positive {
1443        return false;
1444    }
1445    if !has_exclude {
1446        return true;
1447    }
1448    !specs.iter().any(|s| {
1449        pathspec_is_exclude(s) && matches_pathspec_exclude_for_object(s, path, mode, attrs)
1450    })
1451}
1452
1453fn matches_pathspec_exclude_for_object(
1454    spec: &str,
1455    path: &str,
1456    mode: u32,
1457    attrs: AttrLookup<'_>,
1458) -> bool {
1459    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1460    let mut magic = combine_magic(elem_magic);
1461    if !magic.exclude {
1462        return false;
1463    }
1464    magic.exclude = false;
1465    if magic.literal && magic.glob {
1466        return false;
1467    }
1468    let ctx = context_from_mode_bits(mode);
1469    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1470    if !magic.attr_requirements.is_empty()
1471        && !attr_requirements_match(&magic.attr_requirements, attrs, path, is_dir_for_attr, mode)
1472    {
1473        return false;
1474    }
1475    let pattern = strip_top_magic(raw_pattern);
1476    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1477        if !path.starts_with(prefix) {
1478            return false;
1479        }
1480        &path[prefix.len()..]
1481    } else {
1482        path
1483    };
1484    if magic.literal || magic.glob || magic.icase {
1485        pathspec_matches_tail(pattern, path_for_match, magic)
1486    } else {
1487        matches_pathspec_with_context(pattern, path_for_match, ctx)
1488    }
1489}
1490
1491fn pathspec_matches_tail(pattern: &str, path: &str, magic: PathspecMagic) -> bool {
1492    if pattern.is_empty() {
1493        return true;
1494    }
1495
1496    let flags = if magic.icase { WM_CASEFOLD } else { 0 };
1497
1498    if magic.literal {
1499        return literal_prefix_match(pattern, path);
1500    }
1501
1502    let wm_flags = if magic.glob {
1503        flags | WM_PATHNAME
1504    } else {
1505        flags
1506    };
1507
1508    let pattern_bytes = pattern.as_bytes();
1509    let path_bytes = path.as_bytes();
1510    let simple = simple_length(pattern);
1511
1512    // Git `match_pathspec_item`: exact / directory prefix before `git_fnmatch`.
1513    // Only when the pattern has no glob metacharacters (`simple_length` spans the whole pattern);
1514    // otherwise a pattern like `a[a]` must not match children via `a[a]/` prefix (t6130 vs ls-tree).
1515    if ps_str_eq(pattern, path, magic.icase) {
1516        return true;
1517    }
1518    if simple == pattern.len() {
1519        if let Some(prefix) = pattern.strip_suffix('/') {
1520            if ps_str_eq(prefix, path, magic.icase) {
1521                return true;
1522            }
1523            if path_is_under_dir(path, prefix, magic.icase) {
1524                return true;
1525            }
1526        } else if path_is_under_dir(path, pattern, magic.icase) {
1527            return true;
1528        }
1529    }
1530
1531    // `:(glob)**/*.txt` at repo root: Git matches `untracked.txt` (leading `**/` is optional).
1532    if magic.glob && !path.contains('/') && pattern.starts_with("**/") {
1533        if wildmatch(pattern_bytes, path_bytes, wm_flags) {
1534            return true;
1535        }
1536        if let Some(suffix) = pattern.strip_prefix("**/") {
1537            if wildmatch(suffix.as_bytes(), path_bytes, wm_flags) {
1538                return true;
1539            }
1540        }
1541    }
1542
1543    // Wildcard: require literal bytes up to `simple_length`, then wildmatch the tail only.
1544    if simple < pattern.len() {
1545        if path_bytes.len() < simple {
1546            return false;
1547        }
1548        let path_lit = &path_bytes[..simple];
1549        let pat_lit = &pattern_bytes[..simple];
1550        let same = if magic.icase {
1551            path_lit.eq_ignore_ascii_case(pat_lit)
1552        } else {
1553            path_lit == pat_lit
1554        };
1555        if !same {
1556            return false;
1557        }
1558        let pat_rest = &pattern[simple..];
1559        let path_rest = &path[simple..];
1560        return wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), wm_flags);
1561    }
1562
1563    ps_str_eq(pattern, path, magic.icase) || path_is_under_dir(path, pattern, magic.icase)
1564}
1565
1566fn ps_str_eq(a: &str, b: &str, icase: bool) -> bool {
1567    if icase {
1568        a.eq_ignore_ascii_case(b)
1569    } else {
1570        a == b
1571    }
1572}
1573
1574fn path_starts_with(path: &str, prefix: &str, icase: bool) -> bool {
1575    if icase {
1576        path.get(..prefix.len())
1577            .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
1578    } else {
1579        path.starts_with(prefix)
1580    }
1581}
1582
1583/// Whether `path` starts with `dir` followed by `/` (the path lies inside directory `dir`).
1584fn path_is_under_dir(path: &str, dir: &str, icase: bool) -> bool {
1585    path.as_bytes().get(dir.len()) == Some(&b'/') && path_starts_with(path, dir, icase)
1586}
1587
1588fn literal_prefix_match(pattern: &str, path: &str) -> bool {
1589    if let Some(prefix) = pattern.strip_suffix('/') {
1590        return path == prefix || path_is_under_dir(path, prefix, false);
1591    }
1592    path == pattern || path_is_under_dir(path, pattern, false)
1593}
1594
1595/// Literal pathspec match for `ls-tree` when the pattern has no `*`/`?` (brackets stay literal).
1596fn ls_tree_literal_match(pattern: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1597    if let Some(prefix) = pattern.strip_suffix('/') {
1598        if path_is_under_dir(path, prefix, false) {
1599            return true;
1600        }
1601        if path == prefix {
1602            return ctx.is_directory || ctx.is_git_submodule;
1603        }
1604        return false;
1605    }
1606    path == pattern || path_is_under_dir(path, pattern, false)
1607}
1608
1609/// Optional path metadata for literal pathspecs with a trailing `/` (tree-walk / diff-tree).
1610///
1611/// Git treats `dir/` as “directory or git submodule only”: a regular file `dir`
1612/// does not match, but a tree entry `dir` or gitlink `dir` does.
1613#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1614pub struct PathspecMatchContext {
1615    /// The index/tree entry is a directory (mode `040000`).
1616    pub is_directory: bool,
1617    /// The entry is a git submodule / gitlink (`160000`).
1618    pub is_git_submodule: bool,
1619    /// NFC-normalize pathspec and path when matching (per-repository `core.precomposeunicode`).
1620    pub precompose_paths: bool,
1621}
1622
1623/// Returns whether `path` matches the pathspec `spec` with default (file) context.
1624///
1625/// For pathspecs ending in `/`, a path equal to the prefix matches only when
1626/// [`PathspecMatchContext`] indicates a directory or submodule; see
1627/// [`matches_pathspec_with_context`].
1628///
1629/// `:(attr:...)` requirements are not evaluated here (only the pattern part is matched); use
1630/// [`matches_pathspec_for_object`] or [`crate::repo::Repository::paths_match_pathspecs`] for
1631/// specs where [`pathspec_has_attr_magic`] is true.
1632#[must_use]
1633pub fn matches_pathspec(spec: &str, path: &str) -> bool {
1634    matches_pathspec_with_context(spec, path, PathspecMatchContext::default())
1635}
1636
1637/// Like [`matches_pathspec`], but uses `ctx` for trailing-`/` literal pathspecs and for
1638/// wildcard pathspecs where the pattern continues after a directory boundary (Git
1639/// `matches_pathspec` + directory semantics).
1640#[must_use]
1641pub fn matches_pathspec_with_context(spec: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1642    let spec_nfc: Cow<'_, str> = if ctx.precompose_paths {
1643        precompose_utf8_path(spec)
1644    } else {
1645        Cow::Borrowed(spec)
1646    };
1647    let path_nfc: Cow<'_, str> = if ctx.precompose_paths {
1648        precompose_utf8_path(path)
1649    } else {
1650        Cow::Borrowed(path)
1651    };
1652    let spec = spec_nfc.as_ref();
1653    let path = path_nfc.as_ref();
1654
1655    let trimmed = spec.strip_prefix("./").unwrap_or(spec);
1656    if trimmed == "." || trimmed.is_empty() {
1657        return true;
1658    }
1659
1660    let (elem_magic, raw_pattern) = parse_element_magic(trimmed);
1661    let magic = combine_magic(elem_magic);
1662
1663    if magic.literal && magic.glob {
1664        return false;
1665    }
1666    if magic.exclude {
1667        return false;
1668    }
1669
1670    let pattern = strip_top_magic(raw_pattern);
1671    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1672        if !path.starts_with(prefix) {
1673            return false;
1674        }
1675        &path[prefix.len()..]
1676    } else {
1677        path
1678    };
1679
1680    if magic.literal {
1681        if let Some(prefix) = pattern.strip_suffix('/') {
1682            if path_is_under_dir(path_for_match, prefix, false) {
1683                return true;
1684            }
1685            if path_for_match == prefix {
1686                return ctx.is_directory || ctx.is_git_submodule;
1687            }
1688            return false;
1689        }
1690        return path_for_match == pattern || path_is_under_dir(path_for_match, pattern, false);
1691    }
1692
1693    // No wildcards and trailing `/`: directory-only semantics (Git `matches_pathspec`).
1694    if let Some(prefix) = pattern.strip_suffix('/') {
1695        if simple_length(pattern) == pattern.len() {
1696            if path_is_under_dir(path_for_match, prefix, false) {
1697                return true;
1698            }
1699            if path_for_match == prefix {
1700                return ctx.is_directory || ctx.is_git_submodule;
1701            }
1702            return false;
1703        }
1704    }
1705
1706    if pathspec_matches_tail(pattern, path_for_match, magic) {
1707        return true;
1708    }
1709
1710    if (ctx.is_directory || ctx.is_git_submodule)
1711        && !path_for_match.is_empty()
1712        && pattern.len() > path_for_match.len()
1713        && pattern.as_bytes().get(path_for_match.len()) == Some(&b'/')
1714        && pattern.starts_with(path_for_match)
1715        && simple_length(pattern) < pattern.len()
1716    {
1717        return true;
1718    }
1719
1720    false
1721}
1722
1723/// Parse a Git mode string (e.g. `100644`, `040000`) into a [`PathspecMatchContext`].
1724#[must_use]
1725pub fn context_from_mode_octal(mode: &str) -> PathspecMatchContext {
1726    let Ok(bits) = u32::from_str_radix(mode, 8) else {
1727        return PathspecMatchContext::default();
1728    };
1729    context_from_mode_bits(bits)
1730}
1731
1732/// Classify a raw Git mode (e.g. from an index or tree entry) for pathspec matching.
1733#[must_use]
1734pub fn context_from_mode_bits(mode: u32) -> PathspecMatchContext {
1735    let ty = mode & 0o170000;
1736    PathspecMatchContext {
1737        is_directory: ty == 0o040000,
1738        is_git_submodule: ty == 0o160000,
1739        precompose_paths: false,
1740    }
1741}
1742
1743/// Pathspec matching for `ls-tree` after Git forces `pathspec.has_wildcard = 0` (`ls-tree.c`).
1744///
1745/// Metacharacters `*` / `?` still participate in [`wildmatch`]; `[` and `\\` are **not** glob
1746/// starters unless a `*` or `?` appears — so `a[a]` matches the literal directory `a[a]` (t3102),
1747/// while `a*` matches `a/one`, `aa/two`, `a[a]/three`, …
1748#[must_use]
1749pub fn matches_ls_tree_pathspec(
1750    spec: &str,
1751    path: &str,
1752    mode: u32,
1753    attr_rules: &[AttrRule],
1754) -> bool {
1755    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1756    let mut magic = combine_magic(elem_magic);
1757    magic.exclude = false;
1758
1759    if magic.literal && magic.glob {
1760        return false;
1761    }
1762
1763    let ctx = context_from_mode_bits(mode);
1764    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1765
1766    if !magic.attr_requirements.is_empty()
1767        && !attr_requirements_match(
1768            &magic.attr_requirements,
1769            AttrLookup::rules_only(attr_rules),
1770            path,
1771            is_dir_for_attr,
1772            mode,
1773        )
1774    {
1775        return false;
1776    }
1777
1778    let pattern = strip_top_magic(raw_pattern);
1779    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1780        if !path.starts_with(prefix) {
1781            return false;
1782        }
1783        &path[prefix.len()..]
1784    } else {
1785        path
1786    };
1787
1788    if magic.literal || magic.glob || magic.icase {
1789        return pathspec_matches_tail(pattern, path_for_match, magic);
1790    }
1791
1792    let spec_nfc: Cow<'_, str> = Cow::Borrowed(pattern);
1793    let path_nfc: Cow<'_, str> = Cow::Borrowed(path_for_match);
1794    let pattern = spec_nfc.as_ref();
1795    let path = path_nfc.as_ref();
1796
1797    let trimmed = pattern.strip_prefix("./").unwrap_or(pattern);
1798    if trimmed == "." || trimmed.is_empty() {
1799        return true;
1800    }
1801
1802    let uses_star_or_question = trimmed.contains('*') || trimmed.contains('?');
1803    if !uses_star_or_question {
1804        return ls_tree_literal_match(trimmed, path, ctx);
1805    }
1806
1807    let nwl = simple_length(trimmed);
1808    let flags = 0u32;
1809    if nwl == trimmed.len() {
1810        return wildmatch(trimmed.as_bytes(), path.as_bytes(), flags);
1811    }
1812    let lit = trimmed.as_bytes().get(..nwl).unwrap_or_default();
1813    let path_b = path.as_bytes();
1814    if path_b.len() < nwl {
1815        return false;
1816    }
1817    if &path_b[..nwl] != lit {
1818        return false;
1819    }
1820    let pat_rest = &trimmed[nwl..];
1821    let path_rest = &path[nwl..];
1822    wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), flags)
1823}
1824
1825/// Match a pathspec against a tree path, using `.gitattributes` for `:(attr:...)`.
1826///
1827/// `mode` supplies directory/gitlink context for literal pathspecs ending in `/`.
1828#[must_use]
1829pub fn matches_pathspec_for_object(
1830    spec: &str,
1831    path: &str,
1832    mode: u32,
1833    attr_rules: &[AttrRule],
1834) -> bool {
1835    matches_pathspec_for_object_with_attrs(spec, path, mode, AttrLookup::rules_only(attr_rules))
1836}
1837
1838fn matches_pathspec_for_object_with_attrs(
1839    spec: &str,
1840    path: &str,
1841    mode: u32,
1842    attrs: AttrLookup<'_>,
1843) -> bool {
1844    let (elem_magic, raw_pattern) = parse_element_magic(spec);
1845    let mut magic = combine_magic(elem_magic);
1846    magic.exclude = false;
1847
1848    if magic.literal && magic.glob {
1849        return false;
1850    }
1851
1852    let ctx = context_from_mode_bits(mode);
1853    let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1854
1855    if !magic.attr_requirements.is_empty()
1856        && !attr_requirements_match(&magic.attr_requirements, attrs, path, is_dir_for_attr, mode)
1857    {
1858        return false;
1859    }
1860
1861    let pattern = strip_top_magic(raw_pattern);
1862    let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1863        if !path.starts_with(prefix) {
1864            return false;
1865        }
1866        &path[prefix.len()..]
1867    } else {
1868        path
1869    };
1870    if magic.literal || magic.glob || magic.icase {
1871        pathspec_matches_tail(pattern, path_for_match, magic)
1872    } else {
1873        matches_pathspec_with_context(pattern, path_for_match, ctx)
1874    }
1875}
1876
1877/// Resolved path lies outside the repository work tree (Git `prefix_path_gently` failure).
1878#[derive(Debug, Clone)]
1879pub struct PathOutsideRepository {
1880    /// User-facing pathspec token (argv element).
1881    pub elt: String,
1882    /// Resolved absolute path outside the work tree.
1883    pub path: String,
1884    /// Canonical work tree root.
1885    pub work_tree: PathBuf,
1886}
1887
1888impl std::fmt::Display for PathOutsideRepository {
1889    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1890        write!(
1891            f,
1892            "{}: '{}' is outside repository at '{}'",
1893            self.elt,
1894            self.path,
1895            self.work_tree.display()
1896        )
1897    }
1898}
1899
1900impl From<PathOutsideRepository> for PathspecResolveError {
1901    fn from(outside: PathOutsideRepository) -> Self {
1902        Self::OutsideRepository {
1903            elt: outside.elt,
1904            path: outside.path,
1905            work_tree: outside.work_tree.display().to_string(),
1906        }
1907    }
1908}
1909
1910/// Resolve a magic pathspec relative to a current-directory prefix.
1911///
1912/// This keeps the `cwd` prefix case-sensitive (via an internal `prefix:` magic
1913/// token) while still honoring magic options like `icase` for the tail.
1914/// Returns `None` when `spec` is not a parseable magic pathspec.
1915pub fn resolve_magic_pathspec(spec: &str, cwd_prefix: &str) -> Option<String> {
1916    if !spec.starts_with(":(") {
1917        return None;
1918    }
1919    let close_idx = spec.find(')')?;
1920    let magic_prefix = &spec[..=close_idx];
1921    let tail = &spec[close_idx + 1..];
1922    Some(resolve_magic_pathspec_parts(magic_prefix, tail, cwd_prefix))
1923}
1924
1925fn resolve_magic_pathspec_parts(magic_prefix: &str, tail: &str, cwd_prefix: &str) -> String {
1926    if has_magic_prefix_token(magic_prefix) {
1927        return format!("{magic_prefix}{tail}");
1928    }
1929
1930    if let Some(rooted_tail) = tail.strip_prefix('/') {
1931        return format!("{magic_prefix}{}", normalize_relative_path_str(rooted_tail));
1932    }
1933
1934    let combined = if cwd_prefix.is_empty() {
1935        normalize_relative_path_str(tail)
1936    } else {
1937        normalize_relative_path_str(&format!("{cwd_prefix}{tail}"))
1938    };
1939
1940    let cwd_base = normalize_relative_path_str(cwd_prefix.trim_end_matches('/'));
1941    if !cwd_base.is_empty()
1942        && (combined == cwd_base || combined.starts_with(&format!("{cwd_base}/")))
1943    {
1944        let after_base = combined
1945            .strip_prefix(&cwd_base)
1946            .unwrap_or(combined.as_str());
1947        let remainder = after_base.strip_prefix('/').unwrap_or(after_base);
1948        let magic_with_prefix = inject_magic_prefix_token(magic_prefix, &format!("{cwd_base}/"));
1949        return format!("{magic_with_prefix}{remainder}");
1950    }
1951
1952    format!("{magic_prefix}{combined}")
1953}
1954
1955fn has_magic_prefix_token(magic_prefix: &str) -> bool {
1956    let Some(inner) = magic_prefix
1957        .strip_prefix(":(")
1958        .and_then(|s| s.strip_suffix(')'))
1959    else {
1960        return false;
1961    };
1962    inner
1963        .split(',')
1964        .map(str::trim)
1965        .any(|token| token.starts_with("prefix:"))
1966}
1967
1968fn inject_magic_prefix_token(magic_prefix: &str, prefix: &str) -> String {
1969    let Some(inner) = magic_prefix
1970        .strip_prefix(":(")
1971        .and_then(|s| s.strip_suffix(')'))
1972    else {
1973        return magic_prefix.to_string();
1974    };
1975    if inner.trim().is_empty() {
1976        format!(":(prefix:{prefix})")
1977    } else {
1978        format!(":({inner},prefix:{prefix})")
1979    }
1980}
1981
1982/// Drop repeated leading `./` / `.//` from a repo-root pathspec (Git `prefix_path` behavior).
1983///
1984/// When stripping exposes a leading `:`, wrap with `:(literal)` so a filesystem path like
1985/// `:!foo` is not reinterpreted as short exclude magic.
1986fn normalize_repo_root_relative_pathspec(pathspec: &str) -> String {
1987    let mut spec = pathspec;
1988    let mut stripped = false;
1989    while let Some(rest) = spec.strip_prefix("./") {
1990        stripped = true;
1991        spec = rest.trim_start_matches('/');
1992    }
1993    if !stripped {
1994        return pathspec.to_owned();
1995    }
1996    if spec.is_empty() {
1997        return ".".to_owned();
1998    }
1999    if spec.starts_with(':') && !literal_pathspecs_enabled() {
2000        return format!(":(literal){spec}");
2001    }
2002    spec.to_owned()
2003}
2004
2005/// Normalize user pathspec separators on Windows (`\` → `/`) before resolution.
2006fn normalize_pathspec_separators(pathspec: &str) -> std::borrow::Cow<'_, str> {
2007    #[cfg(windows)]
2008    {
2009        if !pathspec.starts_with(':') && pathspec.contains('\\') {
2010            return std::borrow::Cow::Owned(pathspec.replace('\\', "/"));
2011        }
2012    }
2013    std::borrow::Cow::Borrowed(pathspec)
2014}
2015
2016fn normalize_relative_path_str(path: &str) -> String {
2017    let mut parts: Vec<String> = Vec::new();
2018    for component in std::path::Path::new(path).components() {
2019        match component {
2020            std::path::Component::CurDir => {}
2021            std::path::Component::ParentDir => {
2022                parts.pop();
2023            }
2024            std::path::Component::Normal(seg) => {
2025                parts.push(seg.to_string_lossy().to_string());
2026            }
2027            std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
2028        }
2029    }
2030    parts.join("/")
2031}
2032
2033/// Current directory relative to `work_tree`, or `None` if cwd is the work tree root.
2034#[must_use]
2035pub fn pathdiff(cwd: &Path, work_tree: &Path) -> Option<String> {
2036    use crate::git_path::{path_for_disk_compare, path_to_git_slash};
2037
2038    let cwd_cmp = path_for_disk_compare(cwd);
2039    let wt_cmp = path_for_disk_compare(work_tree);
2040
2041    if cwd_cmp == wt_cmp {
2042        return None;
2043    }
2044
2045    let rel = cwd_cmp.strip_prefix(&wt_cmp).ok()?;
2046    let git = path_to_git_slash(rel);
2047    if git.is_empty() {
2048        None
2049    } else {
2050        Some(git)
2051    }
2052}
2053
2054/// For exclude (and other cwd-relative) pathspec magic from a subdirectory, Git resolves the
2055/// pattern against the current directory (`:!sub/` from `repo/sub` → exclude `sub/sub/`).
2056fn prepend_cwd_to_short_exclude_pathspec(spec: &str, cwd: &str) -> Option<String> {
2057    let cwd = cwd.trim_end_matches('/');
2058    if cwd.is_empty() {
2059        return None;
2060    }
2061    let bytes = spec.as_bytes();
2062    if bytes.first().copied() != Some(b':') {
2063        return None;
2064    }
2065    // `:/path` is `:(top)` short form — exclude is relative to repo root, not cwd (t6132).
2066    if bytes.get(1).copied() == Some(b'/') {
2067        return None;
2068    }
2069    let mut i = 1usize;
2070    while i < bytes.len() && bytes[i] != b':' {
2071        let ch = bytes[i];
2072        if ch == b'^' {
2073            i += 1;
2074            continue;
2075        }
2076        let is_magic = matches!(ch, b'!' | b'/');
2077        if is_magic {
2078            i += 1;
2079            continue;
2080        }
2081        break;
2082    }
2083    if i < bytes.len() && bytes[i] == b':' {
2084        i += 1;
2085    }
2086    let pattern = spec.get(i..)?;
2087    if pattern.is_empty() || pattern.starts_with('/') {
2088        return None;
2089    }
2090    Some(format!("{}{}/{pattern}", &spec[..i], cwd))
2091}
2092
2093/// Build [`PathspecMatchContext`] with optional NFC path normalization for comparisons.
2094#[must_use]
2095pub fn pathspec_match_context_with_precompose(precompose_paths: bool) -> PathspecMatchContext {
2096    PathspecMatchContext {
2097        precompose_paths,
2098        ..PathspecMatchContext::default()
2099    }
2100}
2101
2102/// Like [`matches_pathspec_list`], with an explicit `precompose_paths` flag.
2103#[must_use]
2104pub fn matches_pathspec_list_precompose(
2105    path: &str,
2106    specs: &[String],
2107    precompose_paths: bool,
2108) -> bool {
2109    matches_pathspec_list_with_context(
2110        path,
2111        specs,
2112        pathspec_match_context_with_precompose(precompose_paths),
2113    )
2114}
2115
2116/// True when argv pathspec strings must not be NFC-normalized (magic syntax).
2117#[must_use]
2118pub fn argv_pathspec_preserves_spelling(pathspec: &str) -> bool {
2119    pathspec.starts_with(":(")
2120        || pathspec.starts_with(":/")
2121        || pathspec.starts_with(":!")
2122        || pathspec.starts_with(":^")
2123}
2124
2125/// NFC-normalize a literal command-line pathspec (Git `precompose_argv_prefix`).
2126#[must_use]
2127pub fn precompose_argv_pathspec(pathspec: &str) -> Cow<'_, str> {
2128    if argv_pathspec_preserves_spelling(pathspec) {
2129        Cow::Borrowed(pathspec)
2130    } else {
2131        precompose_utf8_path(pathspec)
2132    }
2133}
2134
2135/// Resolve a pathspec string to a path relative to the repository work tree.
2136///
2137/// `prefix` is the current directory relative to the work tree (no trailing slash),
2138/// or `None` when cwd is the work tree root.
2139#[must_use]
2140pub fn resolve_pathspec(pathspec: &str, work_tree: &Path, prefix: Option<&str>) -> String {
2141    resolve_pathspec_with_argv_precompose(pathspec, work_tree, prefix, false)
2142}
2143
2144/// Like [`resolve_pathspec`], but NFC-normalizes literal argv path segments when requested.
2145#[must_use]
2146pub fn resolve_pathspec_with_argv_precompose(
2147    pathspec: &str,
2148    work_tree: &Path,
2149    prefix: Option<&str>,
2150    argv_precompose: bool,
2151) -> String {
2152    let pathspec = if argv_precompose {
2153        precompose_argv_pathspec(pathspec)
2154    } else {
2155        Cow::Borrowed(pathspec)
2156    };
2157    resolve_pathspec_impl(pathspec.as_ref(), work_tree, prefix)
2158}
2159
2160fn resolve_pathspec_impl(pathspec: &str, work_tree: &Path, prefix: Option<&str>) -> String {
2161    let pathspec = normalize_pathspec_separators(pathspec);
2162    let pathspec = pathspec.as_ref();
2163    // Git: `.` at repo root means "match the whole tree" (not an empty pathspec).
2164    // An empty resolved pathspec would match nothing and breaks `grep -- . t` max-depth.
2165    if pathspec == "." {
2166        return match prefix {
2167            Some(p) if !p.is_empty() => p.to_owned(),
2168            _ => ".".to_owned(),
2169        };
2170    }
2171    if pathspec.contains("../") || pathspec.starts_with("../") {
2172        let cwd = match prefix.filter(|p| !p.is_empty()) {
2173            Some(p) => work_tree.join(p),
2174            None => work_tree.to_path_buf(),
2175        };
2176        let abs = cwd.join(pathspec);
2177        let mut parts: Vec<std::ffi::OsString> = Vec::new();
2178        for component in abs.components() {
2179            use std::path::Component;
2180            match component {
2181                Component::ParentDir => {
2182                    parts.pop();
2183                }
2184                Component::CurDir => {}
2185                other => parts.push(other.as_os_str().to_os_string()),
2186            }
2187        }
2188        let abs_norm: PathBuf = parts.iter().collect();
2189        if let Some(rel) = crate::git_path::strip_worktree_prefix(&abs_norm, work_tree) {
2190            return rel;
2191        }
2192    }
2193    if Path::new(pathspec).is_absolute() {
2194        let abs = Path::new(pathspec);
2195        if let Some(rel) = crate::git_path::strip_worktree_prefix(abs, work_tree) {
2196            return rel;
2197        }
2198        return pathspec.to_owned();
2199    }
2200
2201    if pathspec.starts_with(':') {
2202        if let Some(p) = prefix {
2203            if !p.is_empty() && !literal_pathspecs_enabled() {
2204                let cwd_ps = format!("{}/", p.trim_end_matches('/'));
2205                if pathspec.starts_with(":(") {
2206                    if let Some(resolved) = resolve_magic_pathspec(pathspec, &cwd_ps) {
2207                        return resolved;
2208                    }
2209                    return pathspec.to_owned();
2210                }
2211                if pathspec_is_exclude(pathspec) {
2212                    if let Some(fixed) = prepend_cwd_to_short_exclude_pathspec(pathspec, p) {
2213                        return fixed;
2214                    }
2215                }
2216            }
2217        }
2218        if let Some(rest) = pathspec.strip_prefix(":/") {
2219            // `:/!foo` / `:/^bar` — `:/` is `:(top)`; the tail is still short magic, not a literal path.
2220            if rest.starts_with('!') || rest.starts_with('^') {
2221                return pathspec.to_owned();
2222            }
2223            return rest.to_owned();
2224        }
2225        // Long magic `:(...)` must stay intact — `:(exclude)path` is not the same as `path`
2226        // (t6132-pathspec-exclude, grep --untracked with exclude pathspecs).
2227        if pathspec.starts_with(":(") {
2228            return pathspec.to_owned();
2229        }
2230        return pathspec.to_owned();
2231    }
2232
2233    match prefix {
2234        Some(p) if !p.is_empty() => {
2235            normalize_relative_path_str(&PathBuf::from(p).join(pathspec).to_string_lossy())
2236        }
2237        _ => normalize_repo_root_relative_pathspec(pathspec),
2238    }
2239}
2240
2241/// Resolve a pathspec and ensure it lies inside `work_tree` (used by `git add`, etc.).
2242///
2243/// Returns [`PathOutsideRepository`] when resolution stays absolute, matching Git's
2244/// `'%s' is outside repository at '%s'` fatal (t7010).
2245pub fn resolve_pathspec_in_worktree(
2246    elt: &str,
2247    pathspec: &str,
2248    work_tree: &Path,
2249    prefix: Option<&str>,
2250) -> Result<String, PathOutsideRepository> {
2251    resolve_pathspec_in_worktree_with_argv_precompose(elt, pathspec, work_tree, prefix, false)
2252}
2253
2254/// Like [`resolve_pathspec_in_worktree`], NFC-normalizing `pathspec` when `argv_precompose` is set.
2255///
2256/// Callers holding a [`Repository`] should use [`Repository::resolve_worktree_pathspec`], which
2257/// computes `argv_precompose` once per handle instead of reloading config per pathspec.
2258pub(crate) fn resolve_pathspec_in_worktree_with_argv_precompose(
2259    elt: &str,
2260    pathspec: &str,
2261    work_tree: &Path,
2262    prefix: Option<&str>,
2263    argv_precompose: bool,
2264) -> Result<String, PathOutsideRepository> {
2265    let resolved =
2266        resolve_pathspec_with_argv_precompose(pathspec, work_tree, prefix, argv_precompose);
2267    if Path::new(&resolved).is_absolute() {
2268        let wt = work_tree
2269            .canonicalize_simplified()
2270            .unwrap_or_else(|_| work_tree.to_path_buf());
2271        return Err(PathOutsideRepository {
2272            elt: elt.to_string(),
2273            path: resolved,
2274            work_tree: wt,
2275        });
2276    }
2277    Ok(resolved)
2278}
2279
2280/// Normalize a worktree file path for porcelain commands (`blame`, `log`, …).
2281///
2282/// Accepts repo-relative or absolute paths under `work_tree`.
2283#[must_use]
2284pub fn normalize_worktree_file_path(
2285    file_path: &str,
2286    work_tree: &Path,
2287    prefix: Option<&str>,
2288) -> String {
2289    normalize_worktree_file_path_with_git_dir(file_path, work_tree, prefix, None)
2290}
2291
2292/// Like [`normalize_worktree_file_path`], honoring `core.precomposeunicode` argv normalization for `git_dir`.
2293#[must_use]
2294pub fn normalize_worktree_file_path_with_git_dir(
2295    file_path: &str,
2296    work_tree: &Path,
2297    prefix: Option<&str>,
2298    git_dir: Option<&Path>,
2299) -> String {
2300    let argv_precompose = argv_precompose_enabled(git_dir);
2301    let resolved =
2302        resolve_pathspec_with_argv_precompose(file_path, work_tree, prefix, argv_precompose);
2303    if Path::new(&resolved).is_absolute() {
2304        file_path.to_string()
2305    } else {
2306        resolved
2307    }
2308}
2309
2310#[cfg(test)]
2311mod tree_entry_pathspec_tests {
2312    use super::*;
2313
2314    #[test]
2315    fn t6130_bracket_filename_matches_pathspec() {
2316        assert!(matches_pathspec("f[o][o]", "f[o][o]"));
2317        assert!(matches_pathspec(":(glob)f[o][o]", "f[o][o]"));
2318    }
2319
2320    #[test]
2321    fn literal_prefix_and_exact() {
2322        assert!(matches_pathspec("path1", "path1/file1"));
2323        assert!(matches_pathspec_with_context(
2324            "path1/",
2325            "path1/file1",
2326            PathspecMatchContext::default()
2327        ));
2328        assert!(matches_pathspec("file0", "file0"));
2329        assert!(!matches_pathspec("path", "path1/file1"));
2330    }
2331
2332    #[test]
2333    fn dir_prefix_needs_slash_boundary() {
2334        assert!(matches_pathspec("a/b", "a/b/c"));
2335        assert!(!matches_pathspec("a/b", "a/bc"));
2336        assert!(!matches_pathspec("a/b", "a"));
2337        assert!(matches_pathspec(":(literal)dir", "dir/x"));
2338        assert!(!matches_pathspec(":(literal)dir", "dirx"));
2339        assert!(matches_pathspec(":(icase)DIR", "dir/x"));
2340        assert!(!matches_pathspec(":(icase)DIR", "dirx"));
2341        assert!(matches_pathspec("dir/", "dir/x"));
2342        assert!(!matches_pathspec("dir/", "dir"));
2343    }
2344
2345    #[test]
2346    fn sorted_lookup_agrees_with_linear_scan() {
2347        let mut paths: Vec<&str> = vec![
2348            "a",
2349            "a-b",
2350            "a.txt",
2351            "a/b",
2352            "a/b/c.txt",
2353            "a/bc",
2354            "a0",
2355            "b/x[1].txt",
2356            "dir/sub/file",
2357            "dir-sub",
2358            "f[o][o]",
2359            "z",
2360            "caf\u{e9}/menu",
2361            "cafe\u{301}/old",
2362            "\u{212a}.txt",
2363        ];
2364        paths.sort_unstable();
2365        let specs = [
2366            "caf\u{e9}",
2367            "cafe\u{301}/",
2368            "cafe\u{301}/old",
2369            "K.txt",
2370            "\u{212a}.txt",
2371            "a",
2372            "a/",
2373            "a/b",
2374            "a/b/",
2375            "a/bc",
2376            "a-",
2377            "a-b",
2378            "a.txt",
2379            "b",
2380            "b/",
2381            "dir",
2382            "dir/sub",
2383            "dir/sub/",
2384            "dir/su",
2385            "dir-sub",
2386            "f[o][o]",
2387            "*.txt",
2388            "a/*",
2389            ":(glob)**/*.txt",
2390            ":(literal)f[o][o]",
2391            ":(icase)A/B",
2392            ":!a",
2393            ".",
2394            "./a/b",
2395            "",
2396            "z",
2397            "zz",
2398            "/",
2399            "a//",
2400        ];
2401        for ctx in [
2402            PathspecMatchContext::default(),
2403            PathspecMatchContext {
2404                precompose_paths: true,
2405                ..PathspecMatchContext::default()
2406            },
2407            PathspecMatchContext {
2408                is_directory: true,
2409                ..PathspecMatchContext::default()
2410            },
2411        ] {
2412            for spec in specs {
2413                let list = [spec.to_owned()];
2414                let linear = paths
2415                    .iter()
2416                    .any(|p| matches_pathspec_list_with_context(p, &list, ctx));
2417                assert_eq!(
2418                    sorted_paths_match_pathspec(spec, &paths, ctx),
2419                    linear,
2420                    "spec {spec:?} ctx {ctx:?}"
2421                );
2422            }
2423        }
2424        let empty: [&str; 0] = [];
2425        assert!(!sorted_paths_match_pathspec(
2426            "a",
2427            &empty,
2428            PathspecMatchContext::default()
2429        ));
2430    }
2431
2432    #[test]
2433    fn list_matcher_agrees_with_linear_matching() {
2434        let paths = [
2435            "a",
2436            "a-b",
2437            "a.txt",
2438            "a/",
2439            "a/b",
2440            "a/b/c.txt",
2441            "a/bc",
2442            "a//x",
2443            "a0",
2444            "b/x[1].txt",
2445            "dir/sub/file",
2446            "dir-sub",
2447            "f[o][o]",
2448            "z",
2449            "zz/a",
2450            "é/x",
2451            "e\u{301}/y",
2452            "\u{212a}.txt",
2453            "K.txt",
2454            "",
2455        ];
2456        let lists: &[&[&str]] = &[
2457            &[],
2458            &["a"],
2459            &["a/"],
2460            &["a//"],
2461            &["a/b", "dir/sub/", "z"],
2462            &["a/b/", "a/bc", "dir", "zz"],
2463            &["dir/su", "dir-sub", "f[o][o]"],
2464            &["é", "a-"],
2465            &["e\u{301}", "z"],
2466            &["e\u{301}/y", "K.txt"],
2467            &["\u{212a}.txt"],
2468            &["a", "*.txt"],
2469            &["dir", ":!dir/sub"],
2470            &[":!a"],
2471            &["./a", "z"],
2472            &[".", "a"],
2473            &["", "a"],
2474            &[":(icase)A/B", "z"],
2475        ];
2476        for ctx in [
2477            PathspecMatchContext::default(),
2478            PathspecMatchContext {
2479                precompose_paths: true,
2480                ..PathspecMatchContext::default()
2481            },
2482            PathspecMatchContext {
2483                is_directory: true,
2484                ..PathspecMatchContext::default()
2485            },
2486        ] {
2487            for list in lists {
2488                let specs: Vec<String> = list.iter().map(|s| (*s).to_owned()).collect();
2489                let matcher = PathspecListMatcher::new(&specs, ctx);
2490                for path in paths {
2491                    assert_eq!(
2492                        matcher.matches(path),
2493                        matches_pathspec_list_with_context(path, &specs, ctx),
2494                        "path {path:?} specs {specs:?} ctx {ctx:?}"
2495                    );
2496                }
2497            }
2498        }
2499    }
2500
2501    #[test]
2502    fn list_matcher_uses_literal_lookup_only_for_plain_lists() {
2503        let ctx = PathspecMatchContext::default();
2504        let plain = vec!["a/b".to_owned(), "dir/".to_owned()];
2505        assert!(PathspecListMatcher::new(&plain, ctx).literals.is_some());
2506        let glob = vec!["a/b".to_owned(), "*.txt".to_owned()];
2507        assert!(PathspecListMatcher::new(&glob, ctx).literals.is_none());
2508        let exclude = vec!["a/b".to_owned(), ":!a/b/c".to_owned()];
2509        assert!(PathspecListMatcher::new(&exclude, ctx).literals.is_none());
2510        assert!(PathspecListMatcher::new(&[], ctx).literals.is_none());
2511    }
2512
2513    #[test]
2514    fn precomposed_literal_specs_keep_fast_lookups() {
2515        let ctx = PathspecMatchContext {
2516            precompose_paths: true,
2517            ..PathspecMatchContext::default()
2518        };
2519        let specs: Vec<String> = (0..100).map(|i| format!("d{i}/file.txt")).collect();
2520        let matcher = PathspecListMatcher::new(&specs, ctx);
2521        assert!(
2522            matcher.literals.is_some(),
2523            "core.precomposeunicode must not force a per-spec scan of every path"
2524        );
2525        assert!(matcher.matches("d42/file.txt"));
2526        assert!(!matcher.matches("d42/other.txt"));
2527        let nfd = vec!["cafe\u{301}".to_owned()];
2528        let matcher = PathspecListMatcher::new(&nfd, ctx);
2529        assert!(matcher.literals.is_some());
2530        assert!(matcher.matches("caf\u{e9}/menu"));
2531        assert!(matcher.matches("cafe\u{301}/menu"));
2532        assert!(exact_literal_pathspec_set(&specs, ctx).is_none());
2533    }
2534
2535    #[test]
2536    fn ls_tree_bracket_in_name_is_literal_prefix() {
2537        assert!(matches_ls_tree_pathspec(
2538            "a[a]",
2539            "a[a]/three",
2540            0o100644,
2541            &[]
2542        ));
2543        assert!(!matches_pathspec_with_context(
2544            "a[a]",
2545            "a[a]/three",
2546            PathspecMatchContext::default()
2547        ));
2548    }
2549
2550    #[test]
2551    fn wildcards_cross_slash_by_default() {
2552        assert!(matches_pathspec("f*", "file0"));
2553        assert!(matches_pathspec("*file1", "path1/file1"));
2554        assert!(matches_pathspec_with_context(
2555            "path1/f*",
2556            "path1",
2557            PathspecMatchContext {
2558                is_directory: true,
2559                ..Default::default()
2560            }
2561        ));
2562        assert!(matches_pathspec("path1/*file1", "path1/file1"));
2563    }
2564
2565    #[test]
2566    fn glob_double_star_txt_at_repo_root() {
2567        assert!(pathspec_matches(":(glob)**/*.txt", "untracked.txt"));
2568        assert!(pathspec_matches(":(glob)**/*.txt", "d/untracked.txt"));
2569    }
2570
2571    #[test]
2572    fn trailing_slash_directory_only() {
2573        assert!(!matches_pathspec_with_context(
2574            "file0/",
2575            "file0",
2576            PathspecMatchContext::default()
2577        ));
2578        assert!(matches_pathspec_with_context(
2579            "file0/",
2580            "file0",
2581            PathspecMatchContext {
2582                is_directory: true,
2583                ..Default::default()
2584            }
2585        ));
2586        assert!(matches_pathspec_with_context(
2587            "submod/",
2588            "submod",
2589            PathspecMatchContext {
2590                is_git_submodule: true,
2591                ..Default::default()
2592            }
2593        ));
2594    }
2595
2596    #[test]
2597    fn exclude_top_short_magic_subtracts_from_positive() {
2598        let specs = vec!["*".to_string(), ":/!sub2".to_string()];
2599        assert!(matches_pathspec_list("sub/file", &specs));
2600        assert!(!matches_pathspec_list("sub2/file", &specs));
2601        assert!(pathspec_exclude_matches(":/!sub2", "sub2/file"));
2602    }
2603}
2604
2605#[cfg(test)]
2606mod exact_literal_pathspec_set_tests {
2607    use super::*;
2608
2609    #[test]
2610    fn exact_literal_pathspec_set_accepts_file_paths() {
2611        let ctx = PathspecMatchContext::default();
2612        let set = exact_literal_pathspec_set(&["large.txt".to_string()], ctx).expect("set");
2613        assert_eq!(set.len(), 1);
2614        assert!(set.contains("large.txt"));
2615    }
2616
2617    #[test]
2618    fn exact_literal_pathspec_set_rejects_directory_trailing_slash() {
2619        let ctx = PathspecMatchContext::default();
2620        assert!(exact_literal_pathspec_set(&["src/".to_string()], ctx).is_none());
2621    }
2622
2623    #[test]
2624    fn exact_literal_pathspec_set_rejects_glob_magic() {
2625        let ctx = PathspecMatchContext::default();
2626        assert!(exact_literal_pathspec_set(&[":(glob)*.rs".to_string()], ctx).is_none());
2627    }
2628}
2629
2630#[cfg(test)]
2631mod pathspec_list_tests {
2632    use super::*;
2633    use crate::crlf::parse_gitattributes_content;
2634
2635    #[test]
2636    fn exclude_removes_paths_matching_icase_positive() {
2637        let specs = vec![
2638            ":(icase)*.txt".to_string(),
2639            ":(exclude)submodule/subsub/*".to_string(),
2640        ];
2641        assert!(path_allowed_by_pathspec_list(&specs, "submodule/g.txt"));
2642        assert!(!path_allowed_by_pathspec_list(
2643            &specs,
2644            "submodule/subsub/e.txt"
2645        ));
2646    }
2647
2648    #[test]
2649    fn prefixed_attr_exclude_removes_matching_child_path() {
2650        let specs = vec![
2651            "sub".to_string(),
2652            ":(exclude,attr:labelB,prefix:sub/)".to_string(),
2653        ];
2654        let exclude_only = vec![":(exclude,attr:labelB,prefix:sub/)".to_string()];
2655        let attrs = parse_gitattributes_content("fileB labelB\n");
2656        assert!(!matches_pathspec_list_for_object(
2657            "sub/fileB",
2658            0o100644,
2659            &attrs,
2660            &specs,
2661        ));
2662        assert!(!matches_pathspec_list_for_object(
2663            "sub/fileB",
2664            0o100644,
2665            &attrs,
2666            &exclude_only,
2667        ));
2668    }
2669}
2670
2671#[cfg(test)]
2672mod resolve_pathspec_tests {
2673    use super::*;
2674
2675    fn wt() -> PathBuf {
2676        PathBuf::from("/tmp/grit-pathspec-worktree")
2677    }
2678
2679    #[test]
2680    fn strips_leading_dot_slash_at_repo_root() {
2681        let wt = wt();
2682        assert_eq!(resolve_pathspec("./-", &wt, None), "-");
2683        assert_eq!(resolve_pathspec("./a/b", &wt, None), "a/b");
2684        assert_eq!(resolve_pathspec("././a", &wt, None), "a");
2685        assert_eq!(resolve_pathspec(".//a", &wt, None), "a");
2686        assert_eq!(resolve_pathspec("./", &wt, None), ".");
2687        assert_eq!(resolve_pathspec(".", &wt, None), ".");
2688    }
2689
2690    #[test]
2691    fn prefixed_cwd_resolution_unchanged() {
2692        let wt = wt();
2693        assert_eq!(
2694            resolve_pathspec("file", &wt, Some("sub/dir")),
2695            "sub/dir/file"
2696        );
2697        assert_eq!(resolve_pathspec(":(glob)*.rs", &wt, None), ":(glob)*.rs");
2698    }
2699
2700    #[test]
2701    fn dot_slash_colon_filename_stays_literal_not_exclude_magic() {
2702        let wt = wt();
2703        let resolved = resolve_pathspec("./:!foo", &wt, None);
2704        assert_eq!(resolved, ":(literal):!foo");
2705        assert!(!pathspec_is_exclude(&resolved));
2706        assert!(pathspec_matches(&resolved, ":!foo"));
2707        assert!(!pathspec_matches(&resolved, "other"));
2708    }
2709
2710    #[test]
2711    fn resolve_absolute_path_under_worktree() {
2712        let tmp = tempfile::tempdir().expect("tempdir");
2713        let wt = tmp.path().join("repo");
2714        std::fs::create_dir_all(wt.join("d")).expect("mkdir");
2715        let file = wt.join("d/b.txt");
2716        std::fs::write(&file, b"x").expect("write");
2717        let wt = wt.canonicalize_simplified().expect("canonicalize");
2718        let abs = file.canonicalize_simplified().expect("canonicalize file");
2719        let spec = abs.to_string_lossy();
2720        assert_eq!(
2721            resolve_pathspec(&spec, &wt, None),
2722            "d/b.txt",
2723            "absolute forward-slash pathspec"
2724        );
2725    }
2726
2727    #[cfg(windows)]
2728    mod windows {
2729        use super::*;
2730
2731        #[test]
2732        fn resolve_backslash_relative_and_absolute_pathspecs() {
2733            let tmp = tempfile::tempdir().expect("tempdir");
2734            let wt = tmp.path().join("repo");
2735            std::fs::create_dir_all(wt.join("d")).expect("mkdir");
2736            let file_a = wt.join("d/a.txt");
2737            let file_b = wt.join("d/b.txt");
2738            std::fs::write(&file_a, b"a").expect("write");
2739            std::fs::write(&file_b, b"b").expect("write");
2740            let wt = wt.canonicalize_simplified().expect("canonicalize worktree");
2741
2742            assert_eq!(
2743                resolve_pathspec(r"d\a.txt", &wt, None),
2744                "d/a.txt",
2745                "backslash relative pathspec (issue #915 A)"
2746            );
2747
2748            let abs_back = file_b.canonicalize_simplified().expect("canonicalize");
2749            let abs_back_spec = abs_back.display().to_string();
2750            assert_eq!(
2751                resolve_pathspec(&abs_back_spec, &wt, None),
2752                "d/b.txt",
2753                "absolute backslash pathspec (issue #915 B)"
2754            );
2755
2756            let abs_forward = abs_back_spec.replace('\\', "/");
2757            assert_eq!(
2758                resolve_pathspec(&abs_forward, &wt, None),
2759                "d/b.txt",
2760                "absolute forward-slash pathspec (issue #915 C)"
2761            );
2762        }
2763    }
2764
2765    #[test]
2766    fn nfd_pathspec_matches_nfc_index_path_when_precompose_enabled() {
2767        let nfc_path = "caf\u{00e9}.txt";
2768        let nfd_spec = format!("cafe\u{0301}.txt");
2769        assert!(!matches_pathspec_list(&nfc_path, &[nfd_spec.clone()]));
2770        assert!(matches_pathspec_list_precompose(
2771            nfc_path,
2772            &[nfd_spec.clone()],
2773            true
2774        ));
2775    }
2776
2777    #[test]
2778    fn argv_precompose_normalizes_nfd_literal_pathspec() {
2779        let nfd = format!("cafe\u{0301}.txt");
2780        let nfc = precompose_argv_pathspec(&nfd);
2781        assert_eq!(nfc.as_ref(), "caf\u{00e9}.txt");
2782        assert!(argv_pathspec_preserves_spelling(":(glob)*.rs"));
2783        assert_eq!(
2784            precompose_argv_pathspec(":(glob)*.rs").as_ref(),
2785            ":(glob)*.rs"
2786        );
2787    }
2788}
2789
2790#[cfg(test)]
2791mod resolved_pathspecs_tests {
2792    use super::*;
2793    use crate::repo::{InitOptions, Repository};
2794
2795    fn repo_with_tree() -> (tempfile::TempDir, Repository) {
2796        let dir = tempfile::tempdir().expect("tempdir");
2797        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2798        std::fs::write(dir.path().join("top.txt"), b"a").expect("write");
2799        std::fs::create_dir_all(dir.path().join("sub")).expect("mkdir");
2800        std::fs::write(dir.path().join("sub/inner.txt"), b"b").expect("write");
2801        (dir, repo)
2802    }
2803
2804    #[test]
2805    fn resolved_pathspecs_subdir_prefix() {
2806        let (dir, repo) = repo_with_tree();
2807        let sub = dir.path().join("sub");
2808        let resolved =
2809            ResolvedPathspecs::resolve(&repo, &sub, &["inner.txt".to_string()], None).expect("ok");
2810        assert_eq!(resolved.specs, ["sub/inner.txt"]);
2811        assert!(resolved.scoped);
2812    }
2813
2814    #[test]
2815    fn resolved_pathspecs_top_level_magic() {
2816        let (dir, repo) = repo_with_tree();
2817        let resolved =
2818            ResolvedPathspecs::resolve(&repo, dir.path(), &[":/top.txt".to_string()], None)
2819                .expect("ok");
2820        assert_eq!(resolved.specs, ["top.txt"]);
2821    }
2822
2823    #[test]
2824    fn resolved_pathspecs_parent_outside_repo() {
2825        let (dir, repo) = repo_with_tree();
2826        let outside = dir.path().parent().expect("parent").join("outside.txt");
2827        std::fs::write(&outside, b"x").expect("write outside");
2828        let spec = outside.to_string_lossy().into_owned();
2829        let err =
2830            ResolvedPathspecs::resolve(&repo, dir.path(), &[spec], None).expect_err("outside");
2831        assert!(matches!(
2832            err,
2833            PathspecResolveError::OutsideRepository { .. }
2834        ));
2835    }
2836
2837    #[test]
2838    fn resolved_pathspecs_empty_argv() {
2839        let (dir, repo) = repo_with_tree();
2840        let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[String::new()], None)
2841            .expect_err("empty");
2842        assert_eq!(err, PathspecResolveError::EmptyPathspec);
2843    }
2844
2845    #[test]
2846    fn resolved_pathspecs_from_file_lf_and_quoted() {
2847        let (dir, repo) = repo_with_tree();
2848        let input = PathspecInput::new(b"top.txt\n\"sub/inner.txt\"\n".to_vec(), false);
2849        let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
2850        assert_eq!(resolved.specs, ["top.txt", "sub/inner.txt"]);
2851    }
2852
2853    #[test]
2854    fn resolved_pathspecs_from_file_nul() {
2855        let (dir, repo) = repo_with_tree();
2856        let input = PathspecInput::new(b"top.txt\0sub/\0".to_vec(), true);
2857        let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
2858        assert_eq!(resolved.specs, ["top.txt", "sub/"]);
2859    }
2860
2861    #[test]
2862    fn resolved_pathspecs_from_file_nul_preserves_literal_bytes() {
2863        let (dir, repo) = repo_with_tree();
2864        std::fs::write(dir.path().join("  spaced  "), b"x").expect("write");
2865        let quoted_dir = dir.path().join("\"double\"");
2866        std::fs::create_dir_all(&quoted_dir).expect("mkdir");
2867        std::fs::write(quoted_dir.join("f"), b"y").expect("write");
2868        let input = PathspecInput::new(b"  spaced  \0\"double\"\0".to_vec(), true);
2869        let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
2870        assert_eq!(resolved.sources, ["  spaced  ", "\"double\""]);
2871    }
2872
2873    #[test]
2874    fn resolved_pathspecs_from_file_blank_line_errors() {
2875        let (dir, repo) = repo_with_tree();
2876        let input = PathspecInput::new(b"\n".to_vec(), false);
2877        let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
2878        assert_eq!(err, PathspecResolveError::EmptyPathspec);
2879    }
2880
2881    #[test]
2882    fn resolved_pathspecs_from_file_nul_empty_record_errors() {
2883        let (dir, repo) = repo_with_tree();
2884        let input = PathspecInput::new(b"a\0\0b".to_vec(), true);
2885        let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
2886        assert_eq!(err, PathspecResolveError::EmptyPathspec);
2887    }
2888
2889    #[test]
2890    fn resolved_pathspecs_from_file_invalid_quote() {
2891        let (dir, repo) = repo_with_tree();
2892        let input = PathspecInput::new(b"\"bad\\z\"\n".to_vec(), false);
2893        let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
2894        assert!(matches!(
2895            err,
2896            PathspecResolveError::FromFile(
2897                PathspecFromFileParseError::InvalidEscapeSequence { .. }
2898            )
2899        ));
2900        let input = PathspecInput::new(b"\"unterminated\n".to_vec(), false);
2901        let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
2902        assert!(matches!(
2903            err,
2904            PathspecResolveError::FromFile(PathspecFromFileParseError::InvalidCStyleQuoting { .. })
2905        ));
2906    }
2907
2908    #[test]
2909    fn resolved_pathspecs_entire_tree_when_unscoped() {
2910        let (dir, repo) = repo_with_tree();
2911        let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], None).expect("ok");
2912        assert!(resolved.is_entire_tree());
2913        assert!(resolved.specs.is_empty());
2914    }
2915}