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