1use 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#[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#[must_use]
46pub fn has_glob_chars(s: &str) -> bool {
47 simple_length(s) < s.len()
48}
49
50pub 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
103fn 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#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct PathspecInput {
161 pub data: Vec<u8>,
163 pub nul_terminated: bool,
165}
166
167impl PathspecInput {
168 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
180pub struct ResolvedPathspecs {
181 pub specs: Vec<String>,
183 pub sources: Vec<String>,
185 pub scoped: bool,
187}
188
189impl ResolvedPathspecs {
190 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 #[must_use]
272 pub fn is_entire_tree(&self) -> bool {
273 !self.scoped
274 }
275
276 #[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 #[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: bool,
308 prefix: Option<String>,
309 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
332fn 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#[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
361pub 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
463fn 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
480pub 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 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
520fn 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
584fn 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 } _ => 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
619fn 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#[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#[must_use]
700pub fn bloom_lookup_prefix(spec: &str) -> Option<String> {
701 bloom_lookup_prefix_with_cwd(spec, None)
702}
703
704#[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#[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#[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#[must_use]
764pub fn pathspec_matches(spec: &str, path: &str) -> bool {
765 matches_pathspec(spec, path)
766}
767
768#[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#[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#[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#[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#[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#[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#[must_use]
927pub fn pathspec_has_attr_magic(spec: &str) -> bool {
928 spec.starts_with(":(") && spec.contains("attr:") && !attr_clauses(spec).is_empty()
930}
931
932#[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 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 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 #[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 #[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 #[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#[derive(Clone, Copy)]
1030pub(crate) struct AttrLookup<'a> {
1031 rules: &'a [AttrRule],
1032 macros: Option<&'a MacroTable>,
1034 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 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#[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#[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#[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#[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
1242pub(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 ctx.precompose_paths
1267 && sorted_paths
1268 .iter()
1269 .filter(|p| !p.as_ref().is_ascii())
1270 .any(matches_linearly)
1271}
1272
1273fn 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
1291fn 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
1301pub(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
1322fn 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
1338pub(crate) struct PathspecListMatcher<'a> {
1346 specs: &'a [String],
1347 ctx: PathspecMatchContext,
1348 literals: Option<LiteralPathspecSet>,
1349}
1350
1351struct LiteralPathspecSet {
1353 exact: HashSet<String>,
1355 dirs: HashSet<String>,
1357}
1358
1359impl<'a> PathspecListMatcher<'a> {
1360 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 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#[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 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 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 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
1582fn 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
1594fn 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1613pub struct PathspecMatchContext {
1614 pub is_directory: bool,
1616 pub is_git_submodule: bool,
1618 pub precompose_paths: bool,
1620}
1621
1622#[must_use]
1632pub fn matches_pathspec(spec: &str, path: &str) -> bool {
1633 matches_pathspec_with_context(spec, path, PathspecMatchContext::default())
1634}
1635
1636#[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 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#[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#[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#[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#[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#[derive(Debug, Clone)]
1878pub struct PathOutsideRepository {
1879 pub elt: String,
1881 pub path: String,
1883 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
1909pub 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
1981fn 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
2004fn 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#[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
2053fn 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 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#[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#[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#[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#[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#[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#[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 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 if rest.starts_with('!') || rest.starts_with('^') {
2220 return pathspec.to_owned();
2221 }
2222 return rest.to_owned();
2223 }
2224 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
2240pub 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
2253pub(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#[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#[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("ed_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}