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