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 if let Some(prefix) = magic.prefix.as_deref() {
681 let base = prefix.trim_end_matches('/');
682 if base.is_empty() {
683 pattern.to_string()
684 } else {
685 normalize_relative_path_str(&format!("{base}/{pattern}"))
686 }
687 } else {
688 pattern.to_string()
689 };
690 let pattern = combined.as_str();
691 let mut len = simple_length(pattern);
692 if len != pattern.len() {
693 while len > 0 && pattern.as_bytes()[len - 1] != b'/' {
694 len -= 1;
695 }
696 }
697 while len > 0 && pattern.as_bytes()[len - 1] == b'/' {
698 len -= 1;
699 }
700 if len == 0 {
701 return None;
702 }
703 Some(combined[..len].to_string())
704}
705
706#[must_use]
708pub fn bloom_lookup_prefix(spec: &str) -> Option<String> {
709 bloom_lookup_prefix_with_cwd(spec, None)
710}
711
712#[must_use]
714pub fn pathspecs_allow_bloom(specs: &[String]) -> bool {
715 specs.iter().all(|s| {
716 !s.is_empty() && !pathspec_is_exclude(s) && bloom_lookup_prefix_with_cwd(s, None).is_some()
717 })
718}
719
720#[must_use]
725pub fn path_allowed_by_pathspec_list(specs: &[String], path: &str) -> bool {
726 let mut has_positive = false;
727 let mut positive_match = false;
728 for s in specs {
729 let (elem, raw_pattern) = parse_element_magic(s);
730 let magic = combine_magic(elem);
731 if magic.exclude {
732 if path_matches_pathspec_tail(raw_pattern, path, magic) {
733 return false;
734 }
735 continue;
736 }
737 has_positive = true;
738 if pathspec_matches(s, path) {
739 positive_match = true;
740 }
741 }
742 !has_positive || positive_match
743}
744
745#[must_use]
747pub fn pathspec_contributes_match(spec: &str, path: &str) -> bool {
748 pathspec_matches(spec, path) || pathspec_exclude_matches(spec, path)
749}
750
751fn path_for_match_under_magic_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
754 let base = prefix.trim_end_matches('/');
755 if base.is_empty() {
756 return Some(path);
757 }
758 if path == base {
759 return Some("");
760 }
761 if let Some(rest) = path.strip_prefix(prefix) {
762 return Some(rest);
763 }
764 let anchored = if prefix.ends_with('/') {
765 prefix.to_string()
766 } else {
767 format!("{prefix}/")
768 };
769 path.starts_with(&anchored).then(|| &path[anchored.len()..])
770}
771
772fn path_matches_pathspec_tail(raw_pattern: &str, path: &str, magic: PathspecMagic) -> bool {
773 if magic.literal && magic.glob {
774 return false;
775 }
776 let pattern = strip_top_magic(raw_pattern);
777 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
778 match path_for_match_under_magic_prefix(path, prefix) {
779 Some(rest) => rest,
780 None => return false,
781 }
782 } else {
783 path
784 };
785 pathspec_matches_tail(pattern, path_for_match, magic)
786}
787
788#[must_use]
793pub fn pathspec_matches(spec: &str, path: &str) -> bool {
794 matches_pathspec(spec, path)
795}
796
797#[must_use]
799pub fn pathspec_is_exclude(spec: &str) -> bool {
800 let (elem_magic, _) = parse_element_magic(spec);
801 combine_magic(elem_magic).exclude
802}
803
804#[must_use]
810pub(crate) fn pathspec_wants_descent_for_rev_tree(path: &str, specs: &[String]) -> bool {
811 if specs.is_empty() {
812 return true;
813 }
814 for spec in specs {
815 if !pathspec_is_exclude(spec) && pathspec_wants_descent_into_tree(spec, path) {
816 return true;
817 }
818 }
819 if !specs.iter().any(|s| pathspec_is_exclude(s)) {
820 return false;
821 }
822 let ctx = PathspecMatchContext {
823 is_directory: true,
824 is_git_submodule: false,
825 precompose_paths: false,
826 };
827 for spec in specs {
828 if pathspec_is_exclude(spec)
829 && pathspec_exclude_element_matches_with_context(spec, path, ctx)
830 {
831 return false;
832 }
833 }
834 !specs.iter().any(|s| !pathspec_is_exclude(s))
835}
836
837pub(crate) fn pathspec_entry_is_relevant(path: &str, is_tree: bool, specs: &[String]) -> bool {
840 matches_pathspec_list(path, specs)
841 || (is_tree && pathspec_wants_descent_for_rev_tree(path, specs))
842}
843
844#[must_use]
853pub fn pathspec_wants_descent_into_tree(spec: &str, full_name: &str) -> bool {
854 if pathspec_is_exclude(spec) {
855 return false;
856 }
857 let (elem_magic, raw_pattern) = parse_element_magic(spec);
858 let magic = combine_magic(elem_magic);
859 if magic.exclude {
860 return false;
861 }
862 let pattern = strip_top_magic(raw_pattern);
863 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
864 if pattern.is_empty() || pattern == "." {
865 return true;
866 }
867 if let Some(prefix) = magic.prefix.as_deref() {
868 let base = prefix.trim_end_matches('/');
869 if !base.is_empty() && (full_name == base || base.starts_with(&format!("{full_name}/"))) {
870 return true;
871 }
872 }
873 let dir_prefix = format!("{full_name}/");
874 if pattern.starts_with(&dir_prefix) {
875 return true;
876 }
877 if ps_str_eq(pattern, full_name, magic.icase) {
878 return true;
879 }
880 pathspec_recursive_descent_wanted(pattern, magic, full_name)
881}
882
883fn pathspec_literal_descent_wanted(pattern: &str, dir: &str) -> bool {
884 if pattern.is_empty() {
885 return false;
886 }
887 if let Some(prefix) = pattern.strip_suffix('/') {
888 return dir == prefix || path_is_under_dir(dir, prefix, false);
889 }
890 if dir == pattern {
891 return false;
892 }
893 dir.len() < pattern.len()
894 && path_starts_with(pattern, dir, false)
895 && pattern.as_bytes()[dir.len()] == b'/'
896}
897
898fn pathspec_recursive_descent_wanted(pattern: &str, magic: PathspecMagic, dir: &str) -> bool {
899 if magic.literal {
900 return pathspec_literal_descent_wanted(pattern, dir);
901 }
902 if simple_length(pattern) == 0 {
903 return true;
904 }
905
906 let icase = magic.icase;
907 let glob = magic.glob;
908 let components: Vec<&str> = dir.split('/').filter(|c| !c.is_empty()).collect();
909 pathspec_descent_match_components(pattern, icase, glob, &components)
910}
911
912fn pathspec_first_component(pat: &str) -> (&str, &str) {
913 match pat.find('/') {
914 Some(i) => (&pat[..i], &pat[i + 1..]),
915 None => (pat, ""),
916 }
917}
918
919fn pathspec_dir_name_matches_component(
920 pat_comp: &str,
921 dir_name: &str,
922 icase: bool,
923 glob: bool,
924) -> bool {
925 if pat_comp.is_empty() {
926 return true;
927 }
928 let nwl = simple_length(pat_comp);
929 if nwl == 0 {
930 return true;
931 }
932 if nwl >= pat_comp.len() {
933 return ps_str_eq(pat_comp, dir_name, icase);
934 }
935 let lit = &pat_comp[..nwl];
936 let lit_bytes = lit.as_bytes();
937 let dir_bytes = dir_name.as_bytes();
938 if icase {
939 if dir_bytes.len() < lit_bytes.len()
940 || !dir_bytes[..lit_bytes.len()].eq_ignore_ascii_case(lit_bytes)
941 {
942 return false;
943 }
944 } else if dir_bytes.len() < lit_bytes.len() || &dir_bytes[..lit_bytes.len()] != lit_bytes {
945 return false;
946 }
947 let flags = if icase { WM_CASEFOLD } else { 0 };
948 let wm_flags = if glob { flags | WM_PATHNAME } else { flags };
949 wildmatch(pat_comp.as_bytes(), dir_name.as_bytes(), wm_flags)
950}
951
952fn pathspec_descent_match_components(
953 pat: &str,
954 icase: bool,
955 glob: bool,
956 dir_components: &[&str],
957) -> bool {
958 if dir_components.is_empty() {
959 return simple_length(pat) == 0 || !pat.is_empty();
960 }
961 if simple_length(pat) == 0 {
962 return true;
963 }
964 let (pat_comp, pat_rest) = pathspec_first_component(pat);
965 if !pathspec_dir_name_matches_component(pat_comp, dir_components[0], icase, glob) {
966 return false;
967 }
968 pathspec_descent_match_components(pat_rest, icase, glob, &dir_components[1..])
969}
970
971#[cfg(test)]
972mod pathspec_descent_tests {
973 use super::*;
974
975 #[test]
976 fn descent_wildcard_non_final_component() {
977 assert!(pathspec_wants_descent_into_tree("*/g.c", "a"));
978 assert!(pathspec_wants_descent_into_tree("a/*/f.c", "a"));
979 assert!(pathspec_wants_descent_into_tree("a/*/f.c", "a/b"));
980 assert!(pathspec_wants_descent_into_tree(":(glob)**/f.c", "a"));
981 assert!(pathspec_wants_descent_into_tree(":(glob)*/g.c", "a"));
982 assert!(pathspec_wants_descent_into_tree(":(icase)A/g.c", "a"));
983 assert!(!pathspec_wants_descent_into_tree(":(literal)*g.c", "a"));
984 assert!(!pathspec_wants_descent_into_tree("a/*.c", "b"));
985 assert!(pathspec_wants_descent_into_tree("b*/log.c", "builtin"));
986 assert!(!pathspec_wants_descent_into_tree("b*/log.c", "other"));
987 assert!(pathspec_wants_descent_into_tree("bu?ltin/log.c", "builtin"));
988 assert!(pathspec_wants_descent_into_tree(
989 ":(icase)B*/LOG.C",
990 "builtin"
991 ));
992 assert!(!pathspec_wants_descent_into_tree(":(icase)b*/log.c", "é"));
993 }
994
995 #[test]
996 fn glob_doublestar_path_matches_nested_file() {
997 let specs = vec![":(glob)**/f.c".to_owned()];
998 assert!(matches_pathspec_list("a/b/f.c", &specs));
999 }
1000}
1001
1002#[must_use]
1005pub fn matches_pathspec_set_for_object_ls_tree(
1006 specs: &[String],
1007 path: &str,
1008 mode: u32,
1009 attr_rules: &[AttrRule],
1010) -> bool {
1011 if specs.is_empty() {
1012 return true;
1013 }
1014 let mut positives: Vec<&str> = Vec::new();
1015 let mut excludes: Vec<&str> = Vec::new();
1016 for s in specs {
1017 if pathspec_is_exclude(s) {
1018 excludes.push(s.as_str());
1019 } else {
1020 positives.push(s.as_str());
1021 }
1022 }
1023 let positive_ok = if positives.is_empty() {
1024 true
1025 } else {
1026 positives
1027 .iter()
1028 .any(|s| matches_ls_tree_pathspec(s, path, mode, attr_rules))
1029 };
1030 if !positive_ok {
1031 return false;
1032 }
1033 for ex in excludes {
1034 if matches_ls_tree_pathspec(ex, path, mode, attr_rules) {
1035 return false;
1036 }
1037 }
1038 true
1039}
1040
1041#[must_use]
1044pub fn matches_pathspec_set_for_object(
1045 specs: &[String],
1046 path: &str,
1047 mode: u32,
1048 attr_rules: &[AttrRule],
1049) -> bool {
1050 if specs.is_empty() {
1051 return true;
1052 }
1053 let mut positives: Vec<&str> = Vec::new();
1054 let mut excludes: Vec<&str> = Vec::new();
1055 for s in specs {
1056 if pathspec_is_exclude(s) {
1057 excludes.push(s.as_str());
1058 } else {
1059 positives.push(s.as_str());
1060 }
1061 }
1062 let positive_ok = if positives.is_empty() {
1063 true
1064 } else {
1065 positives
1066 .iter()
1067 .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
1068 };
1069 if !positive_ok {
1070 return false;
1071 }
1072 for ex in excludes {
1073 if matches_pathspec_for_object(ex, path, mode, attr_rules) {
1074 return false;
1075 }
1076 }
1077 true
1078}
1079
1080#[must_use]
1082pub fn pathspec_has_top(spec: &str) -> bool {
1083 let (elem_magic, _) = parse_element_magic(spec);
1084 combine_magic(elem_magic).top
1085}
1086
1087#[must_use]
1094pub(crate) fn pathspec_has_attr_magic(spec: &str) -> bool {
1095 spec.starts_with(":(") && spec.contains("attr:") && !attr_clauses(spec).is_empty()
1097}
1098
1099#[derive(Debug, Clone)]
1108pub struct PathspecMatcher<'s> {
1109 specs: Cow<'s, [String]>,
1110 precompose: bool,
1111 attrs: Option<std::sync::Arc<crate::attributes::ParsedGitAttributes>>,
1112 attr_icase: bool,
1114}
1115
1116impl<'s> PathspecMatcher<'s> {
1117 pub(crate) fn new(
1119 specs: &'s [String],
1120 precompose: bool,
1121 attrs: Option<std::sync::Arc<crate::attributes::ParsedGitAttributes>>,
1122 attr_icase: bool,
1123 ) -> Self {
1124 let specs = if precompose && attrs.is_some() {
1126 Cow::Owned(
1127 specs
1128 .iter()
1129 .map(|spec| precompose_utf8_path(spec).into_owned())
1130 .collect(),
1131 )
1132 } else {
1133 Cow::Borrowed(specs)
1134 };
1135 Self {
1136 specs,
1137 precompose,
1138 attrs,
1139 attr_icase,
1140 }
1141 }
1142
1143 #[must_use]
1146 pub fn matches(&self, path: &str) -> bool {
1147 const REGULAR_FILE: u32 = 0o100644;
1148 self.matches_entry(path, REGULAR_FILE)
1149 }
1150
1151 #[must_use]
1156 pub fn matches_entry(&self, path: &str, mode: u32) -> bool {
1157 let ctx = context_from_mode_bits(mode);
1158 let match_ctx = PathspecMatchContext {
1159 is_directory: ctx.is_directory,
1160 is_git_submodule: ctx.is_git_submodule,
1161 precompose_paths: self.precompose,
1162 };
1163 let Some(attrs) = &self.attrs else {
1164 return matches_pathspec_list_with_context(path, &self.specs, match_ctx);
1165 };
1166 let path = if self.precompose {
1167 precompose_utf8_path(path)
1168 } else {
1169 Cow::Borrowed(path)
1170 };
1171 let lookup = AttrLookup::new(&attrs.rules, &attrs.macros, self.attr_icase);
1172 matches_pathspec_list_with_attrs(&path, mode, lookup, &self.specs)
1173 }
1174
1175 #[must_use]
1178 pub fn matches_change(&self, path: &str, old_path: Option<&str>) -> bool {
1179 self.specs.is_empty()
1180 || self.matches(path)
1181 || old_path.is_some_and(|old| old != path && self.matches(old))
1182 }
1183
1184 #[must_use]
1186 pub fn is_empty(&self) -> bool {
1187 self.specs.is_empty()
1188 }
1189}
1190
1191fn pathspec_match_one_positive(path: &str, magic: PathspecMagic, raw_pattern: &str) -> bool {
1192 if magic.literal && magic.glob {
1193 return false;
1194 }
1195 let pattern = strip_top_magic(raw_pattern);
1196 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1197 match path_for_match_under_magic_prefix(path, prefix) {
1198 Some(rest) => rest,
1199 None => return false,
1200 }
1201 } else {
1202 path
1203 };
1204 pathspec_matches_tail(pattern, path_for_match, magic)
1205}
1206
1207#[derive(Clone, Copy)]
1209pub(crate) struct AttrLookup<'a> {
1210 rules: &'a [AttrRule],
1211 macros: Option<&'a MacroTable>,
1213 icase: bool,
1215}
1216
1217impl<'a> AttrLookup<'a> {
1218 pub fn new(rules: &'a [AttrRule], macros: &'a MacroTable, icase: bool) -> Self {
1219 Self {
1220 rules,
1221 macros: Some(macros),
1222 icase,
1223 }
1224 }
1225
1226 fn rules_only(rules: &'a [AttrRule]) -> Self {
1227 Self {
1228 rules,
1229 macros: None,
1230 icase: false,
1231 }
1232 }
1233}
1234
1235fn attr_requirements_match(
1236 requirements: &[AttrRequirement],
1237 attrs: AttrLookup<'_>,
1238 path: &str,
1239 is_dir: bool,
1240 mode: u32,
1241) -> bool {
1242 let no_macros = MacroTable::default();
1243 let assigned = path_gitattribute_values(
1244 attrs.rules,
1245 attrs.macros.unwrap_or(&no_macros),
1246 attrs.icase,
1247 path,
1248 is_dir,
1249 );
1250 requirements.iter().all(|req| {
1251 let value: Option<Cow<'_, str>> = if req.name() == "builtin_objectmode" {
1252 (mode != 0).then(|| Cow::Owned(format!("{mode:06o}")))
1253 } else {
1254 assigned
1255 .get(req.name())
1256 .map(|v| Cow::Borrowed(v.display()))
1257 .filter(|v| v != "unspecified")
1258 };
1259 match req {
1260 AttrRequirement::Set(_) => value.as_deref() == Some("set"),
1261 AttrRequirement::Unset(_) => value.as_deref() == Some("unset"),
1262 AttrRequirement::Unspecified(_) => value.is_none(),
1263 AttrRequirement::Value(_, expected) => value.as_deref() == Some(expected.as_str()),
1264 }
1265 })
1266}
1267
1268fn matches_pathspec_element_with_context(
1269 spec: &str,
1270 path: &str,
1271 ctx: PathspecMatchContext,
1272) -> bool {
1273 let (elem_magic, raw_pattern) = parse_element_magic(spec);
1274 let magic = combine_magic(elem_magic);
1275 if magic.exclude {
1276 return false;
1277 }
1278 if magic.literal && magic.glob {
1279 return false;
1280 }
1281 if !magic.attr_requirements.is_empty() {
1282 return false;
1283 }
1284 if magic.literal || magic.glob || magic.icase {
1285 return pathspec_matches(spec, path);
1286 }
1287 let pattern = strip_top_magic(raw_pattern);
1288 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1289 match path_for_match_under_magic_prefix(path, prefix) {
1290 Some(rest) => rest,
1291 None => return false,
1292 }
1293 } else {
1294 path
1295 };
1296 matches_pathspec_with_context(pattern, path_for_match, ctx)
1297}
1298
1299fn pathspec_exclude_element_matches_with_context(
1300 spec: &str,
1301 path: &str,
1302 ctx: PathspecMatchContext,
1303) -> bool {
1304 let (elem_magic, raw_pattern) = parse_element_magic(spec);
1305 let mut magic = combine_magic(elem_magic);
1306 if !magic.exclude {
1307 return false;
1308 }
1309 magic.exclude = false;
1310 if magic.literal && magic.glob {
1311 return false;
1312 }
1313 if !magic.attr_requirements.is_empty() {
1314 return false;
1317 }
1318 if magic.literal || magic.glob || magic.icase {
1319 return pathspec_match_one_positive(path, magic, raw_pattern);
1320 }
1321 let pattern = strip_top_magic(raw_pattern);
1322 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1323 match path_for_match_under_magic_prefix(path, prefix) {
1324 Some(rest) => rest,
1325 None => return false,
1326 }
1327 } else {
1328 path
1329 };
1330 matches_pathspec_with_context(pattern, path_for_match, ctx)
1331}
1332
1333#[must_use]
1336pub fn pathspec_exclude_matches(spec: &str, path: &str) -> bool {
1337 pathspec_exclude_element_matches_with_context(spec, path, PathspecMatchContext::default())
1338}
1339
1340#[must_use]
1345pub fn extend_pathspec_list_implicit_cwd(
1346 specs: &[String],
1347 cwd_from_repo_root: Option<&str>,
1348) -> Vec<String> {
1349 if specs.is_empty() {
1350 return specs.to_vec();
1351 }
1352 if !specs.iter().all(|s| pathspec_is_exclude(s)) {
1353 return specs.to_vec();
1354 }
1355 let any_top = specs.iter().any(|s| pathspec_has_top(s));
1356 if any_top {
1357 return specs.to_vec();
1358 }
1359 let Some(cwd) = cwd_from_repo_root.map(str::trim).filter(|s| !s.is_empty()) else {
1360 return specs.to_vec();
1361 };
1362 let cwd = cwd.trim_end_matches('/');
1363 if cwd.is_empty() {
1364 return specs.to_vec();
1365 }
1366 let mut out = Vec::with_capacity(specs.len() + 1);
1367 out.push(".".to_owned());
1369 out.extend_from_slice(specs);
1370 out
1371}
1372
1373#[must_use]
1382pub fn matches_pathspec_list(path: &str, specs: &[String]) -> bool {
1383 matches_pathspec_list_with_context(path, specs, PathspecMatchContext::default())
1384}
1385
1386#[must_use]
1391pub fn matches_pathspec_list_with_context(
1392 path: &str,
1393 specs: &[String],
1394 ctx: PathspecMatchContext,
1395) -> bool {
1396 if specs.is_empty() {
1397 return true;
1398 }
1399 let mut has_exclude = false;
1400 let mut has_positive = false;
1401 let mut positive = false;
1402 for spec in specs {
1403 if pathspec_is_exclude(spec) {
1404 has_exclude = true;
1405 } else {
1406 has_positive = true;
1407 positive = positive || matches_pathspec_element_with_context(spec, path, ctx);
1408 }
1409 }
1410 if has_positive && !positive {
1411 return false;
1412 }
1413 if !has_exclude {
1414 return true;
1415 }
1416 let excluded = specs.iter().any(|s| {
1417 pathspec_is_exclude(s) && pathspec_exclude_element_matches_with_context(s, path, ctx)
1418 });
1419 !excluded
1420}
1421
1422pub(crate) fn sorted_paths_match_pathspec<S: AsRef<str>>(
1431 spec: &str,
1432 sorted_paths: &[S],
1433 ctx: PathspecMatchContext,
1434) -> bool {
1435 let list = [spec.to_owned()];
1436 let matches_linearly = |p: &S| matches_pathspec_list_with_context(p.as_ref(), &list, ctx);
1437 if !is_plain_literal_pathspec(spec, ctx) {
1438 return sorted_paths.iter().any(matches_linearly);
1439 }
1440 let spec_nfc = literal_spec_for_lookup(spec, ctx);
1441 if sorted_literal_lookup(&spec_nfc, sorted_paths) {
1442 return true;
1443 }
1444 ctx.precompose_paths
1447 && sorted_paths
1448 .iter()
1449 .filter(|p| !p.as_ref().is_ascii())
1450 .any(matches_linearly)
1451}
1452
1453fn sorted_literal_lookup<S: AsRef<str>>(spec: &str, sorted_paths: &[S]) -> bool {
1455 let has_path_with_prefix = |prefix: &str| {
1456 let at = sorted_paths.partition_point(|p| p.as_ref() < prefix);
1457 sorted_paths
1458 .get(at)
1459 .is_some_and(|p| p.as_ref().starts_with(prefix))
1460 };
1461 if spec.ends_with('/') {
1462 return has_path_with_prefix(spec);
1463 }
1464 let at = sorted_paths.partition_point(|p| p.as_ref() < spec);
1465 if sorted_paths.get(at).is_some_and(|p| p.as_ref() == spec) {
1466 return true;
1467 }
1468 has_path_with_prefix(&format!("{spec}/"))
1469}
1470
1471pub(crate) fn literal_spec_for_lookup(spec: &str, ctx: PathspecMatchContext) -> Cow<'_, str> {
1474 if ctx.precompose_paths {
1475 precompose_utf8_path(spec)
1476 } else {
1477 Cow::Borrowed(spec)
1478 }
1479}
1480
1481pub(crate) fn exact_literal_pathspec_set(
1487 specs: &[String],
1488 ctx: PathspecMatchContext,
1489) -> Option<std::collections::BTreeSet<String>> {
1490 if specs.is_empty() || ctx.precompose_paths {
1491 return None;
1492 }
1493 if !specs
1494 .iter()
1495 .all(|s| is_plain_literal_pathspec(s, ctx) && !s.ends_with('/'))
1496 {
1497 return None;
1498 }
1499 Some(specs.iter().cloned().collect())
1500}
1501
1502pub(crate) fn is_plain_literal_pathspec(spec: &str, ctx: PathspecMatchContext) -> bool {
1508 !ctx.is_directory
1509 && !ctx.is_git_submodule
1510 && !spec.is_empty()
1511 && spec != "."
1512 && !spec.starts_with("./")
1513 && !spec.starts_with(':')
1514 && simple_length(spec) == spec.len()
1515 && !(literal_global() || glob_global() || noglob_global() || icase_global())
1516}
1517
1518pub(crate) fn repository_path_as_literal_pathspec(path: &str) -> String {
1523 let ctx = PathspecMatchContext::default();
1524 if is_plain_literal_pathspec(path, ctx) {
1525 path.to_owned()
1526 } else {
1527 format!(":(literal){path}")
1528 }
1529}
1530
1531pub(crate) struct PathspecListMatcher<'a> {
1539 specs: &'a [String],
1540 ctx: PathspecMatchContext,
1541 literals: Option<LiteralPathspecSet>,
1542}
1543
1544struct LiteralPathspecSet {
1546 exact: HashSet<String>,
1548 dirs: HashSet<String>,
1550}
1551
1552impl<'a> PathspecListMatcher<'a> {
1553 pub fn new(specs: &'a [String], ctx: PathspecMatchContext) -> Self {
1555 let all_plain =
1556 !specs.is_empty() && specs.iter().all(|s| is_plain_literal_pathspec(s, ctx));
1557 let literals = all_plain.then(|| {
1558 let mut set = LiteralPathspecSet {
1559 exact: HashSet::with_capacity(specs.len()),
1560 dirs: HashSet::with_capacity(specs.len()),
1561 };
1562 for spec in specs {
1563 let spec = literal_spec_for_lookup(spec, ctx);
1564 match spec.strip_suffix('/') {
1565 Some(dir) => {
1566 set.dirs.insert(dir.to_owned());
1567 }
1568 None => {
1569 set.exact.insert(spec.to_string());
1570 set.dirs.insert(spec.into_owned());
1571 }
1572 }
1573 }
1574 set
1575 });
1576 Self {
1577 specs,
1578 ctx,
1579 literals,
1580 }
1581 }
1582
1583 pub(crate) fn matches(&self, path: &str) -> bool {
1585 let Some(set) = &self.literals else {
1586 return matches_pathspec_list_with_context(path, self.specs, self.ctx);
1587 };
1588 let path = literal_spec_for_lookup(path, self.ctx);
1589 let path = path.as_ref();
1590 if set.exact.contains(path) {
1591 return true;
1592 }
1593 path.bytes()
1594 .enumerate()
1595 .any(|(i, b)| b == b'/' && set.dirs.contains(&path[..i]))
1596 }
1597}
1598
1599#[must_use]
1605pub fn matches_pathspec_list_for_object(
1606 path: &str,
1607 mode: u32,
1608 attr_rules: &[AttrRule],
1609 specs: &[String],
1610) -> bool {
1611 matches_pathspec_list_with_attrs(path, mode, AttrLookup::rules_only(attr_rules), specs)
1612}
1613
1614pub(crate) fn matches_pathspec_list_with_attrs(
1615 path: &str,
1616 mode: u32,
1617 attrs: AttrLookup<'_>,
1618 specs: &[String],
1619) -> bool {
1620 if specs.is_empty() {
1621 return true;
1622 }
1623 let mut has_exclude = false;
1624 let mut has_positive = false;
1625 let mut positive = false;
1626 for spec in specs {
1627 if pathspec_is_exclude(spec) {
1628 has_exclude = true;
1629 } else {
1630 has_positive = true;
1631 positive = positive || matches_pathspec_for_object_with_attrs(spec, path, mode, attrs);
1632 }
1633 }
1634 if has_positive && !positive {
1635 return false;
1636 }
1637 if !has_exclude {
1638 return true;
1639 }
1640 !specs.iter().any(|s| {
1641 pathspec_is_exclude(s) && matches_pathspec_exclude_for_object(s, path, mode, attrs)
1642 })
1643}
1644
1645fn matches_pathspec_exclude_for_object(
1646 spec: &str,
1647 path: &str,
1648 mode: u32,
1649 attrs: AttrLookup<'_>,
1650) -> bool {
1651 let (elem_magic, raw_pattern) = parse_element_magic(spec);
1652 let mut magic = combine_magic(elem_magic);
1653 if !magic.exclude {
1654 return false;
1655 }
1656 magic.exclude = false;
1657 if magic.literal && magic.glob {
1658 return false;
1659 }
1660 let ctx = context_from_mode_bits(mode);
1661 let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1662 if !magic.attr_requirements.is_empty()
1663 && !attr_requirements_match(&magic.attr_requirements, attrs, path, is_dir_for_attr, mode)
1664 {
1665 return false;
1666 }
1667 let pattern = strip_top_magic(raw_pattern);
1668 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1669 match path_for_match_under_magic_prefix(path, prefix) {
1670 Some(rest) => rest,
1671 None => return false,
1672 }
1673 } else {
1674 path
1675 };
1676 if magic.literal || magic.glob || magic.icase {
1677 pathspec_matches_tail(pattern, path_for_match, magic)
1678 } else {
1679 matches_pathspec_with_context(pattern, path_for_match, ctx)
1680 }
1681}
1682
1683fn pathspec_matches_tail(pattern: &str, path: &str, magic: PathspecMagic) -> bool {
1684 if pattern.is_empty() {
1685 return true;
1686 }
1687
1688 let flags = if magic.icase { WM_CASEFOLD } else { 0 };
1689
1690 if magic.literal {
1691 return literal_prefix_match(pattern, path);
1692 }
1693
1694 let wm_flags = if magic.glob {
1695 flags | WM_PATHNAME
1696 } else {
1697 flags
1698 };
1699
1700 let pattern_bytes = pattern.as_bytes();
1701 let path_bytes = path.as_bytes();
1702 let simple = simple_length(pattern);
1703
1704 if ps_str_eq(pattern, path, magic.icase) {
1708 return true;
1709 }
1710 if simple == pattern.len() {
1711 if let Some(prefix) = pattern.strip_suffix('/') {
1712 if ps_str_eq(prefix, path, magic.icase) {
1713 return true;
1714 }
1715 if path_is_under_dir(path, prefix, magic.icase) {
1716 return true;
1717 }
1718 } else if path_is_under_dir(path, pattern, magic.icase) {
1719 return true;
1720 }
1721 }
1722
1723 if magic.glob && !path.contains('/') && pattern.starts_with("**/") {
1725 if wildmatch(pattern_bytes, path_bytes, wm_flags) {
1726 return true;
1727 }
1728 if let Some(suffix) = pattern.strip_prefix("**/") {
1729 if wildmatch(suffix.as_bytes(), path_bytes, wm_flags) {
1730 return true;
1731 }
1732 }
1733 }
1734
1735 if simple < pattern.len() {
1737 if path_bytes.len() < simple {
1738 return false;
1739 }
1740 let path_lit = &path_bytes[..simple];
1741 let pat_lit = &pattern_bytes[..simple];
1742 let same = if magic.icase {
1743 path_lit.eq_ignore_ascii_case(pat_lit)
1744 } else {
1745 path_lit == pat_lit
1746 };
1747 if !same {
1748 return false;
1749 }
1750 let pat_rest = &pattern[simple..];
1751 let path_rest = &path[simple..];
1752 return wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), wm_flags);
1753 }
1754
1755 ps_str_eq(pattern, path, magic.icase) || path_is_under_dir(path, pattern, magic.icase)
1756}
1757
1758fn ps_str_eq(a: &str, b: &str, icase: bool) -> bool {
1759 if icase {
1760 a.eq_ignore_ascii_case(b)
1761 } else {
1762 a == b
1763 }
1764}
1765
1766fn path_starts_with(path: &str, prefix: &str, icase: bool) -> bool {
1767 if icase {
1768 path.get(..prefix.len())
1769 .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
1770 } else {
1771 path.starts_with(prefix)
1772 }
1773}
1774
1775fn path_is_under_dir(path: &str, dir: &str, icase: bool) -> bool {
1777 path.as_bytes().get(dir.len()) == Some(&b'/') && path_starts_with(path, dir, icase)
1778}
1779
1780fn literal_prefix_match(pattern: &str, path: &str) -> bool {
1781 if let Some(prefix) = pattern.strip_suffix('/') {
1782 return path == prefix || path_is_under_dir(path, prefix, false);
1783 }
1784 path == pattern || path_is_under_dir(path, pattern, false)
1785}
1786
1787fn ls_tree_literal_match(pattern: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1789 if let Some(prefix) = pattern.strip_suffix('/') {
1790 if path_is_under_dir(path, prefix, false) {
1791 return true;
1792 }
1793 if path == prefix {
1794 return ctx.is_directory || ctx.is_git_submodule;
1795 }
1796 return false;
1797 }
1798 path == pattern || path_is_under_dir(path, pattern, false)
1799}
1800
1801#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1806pub struct PathspecMatchContext {
1807 pub is_directory: bool,
1809 pub is_git_submodule: bool,
1811 pub precompose_paths: bool,
1813}
1814
1815#[must_use]
1825pub fn matches_pathspec(spec: &str, path: &str) -> bool {
1826 matches_pathspec_with_context(spec, path, PathspecMatchContext::default())
1827}
1828
1829#[must_use]
1833pub fn matches_pathspec_with_context(spec: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1834 let spec_nfc: Cow<'_, str> = if ctx.precompose_paths {
1835 precompose_utf8_path(spec)
1836 } else {
1837 Cow::Borrowed(spec)
1838 };
1839 let path_nfc: Cow<'_, str> = if ctx.precompose_paths {
1840 precompose_utf8_path(path)
1841 } else {
1842 Cow::Borrowed(path)
1843 };
1844 let spec = spec_nfc.as_ref();
1845 let path = path_nfc.as_ref();
1846
1847 let trimmed = spec.strip_prefix("./").unwrap_or(spec);
1848 if trimmed == "." || trimmed.is_empty() {
1849 return true;
1850 }
1851
1852 let (elem_magic, raw_pattern) = parse_element_magic(trimmed);
1853 let magic = combine_magic(elem_magic);
1854
1855 if magic.literal && magic.glob {
1856 return false;
1857 }
1858 if magic.exclude {
1859 return false;
1860 }
1861
1862 let pattern = strip_top_magic(raw_pattern);
1863 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1864 match path_for_match_under_magic_prefix(path, prefix) {
1865 Some(rest) => rest,
1866 None => return false,
1867 }
1868 } else {
1869 path
1870 };
1871
1872 if magic.literal {
1873 if let Some(prefix) = pattern.strip_suffix('/') {
1874 if path_is_under_dir(path_for_match, prefix, false) {
1875 return true;
1876 }
1877 if path_for_match == prefix {
1878 return ctx.is_directory || ctx.is_git_submodule;
1879 }
1880 return false;
1881 }
1882 return path_for_match == pattern || path_is_under_dir(path_for_match, pattern, false);
1883 }
1884
1885 if let Some(prefix) = pattern.strip_suffix('/') {
1887 if simple_length(pattern) == pattern.len() {
1888 if path_is_under_dir(path_for_match, prefix, false) {
1889 return true;
1890 }
1891 if path_for_match == prefix {
1892 return ctx.is_directory || ctx.is_git_submodule;
1893 }
1894 return false;
1895 }
1896 }
1897
1898 if pathspec_matches_tail(pattern, path_for_match, magic) {
1899 return true;
1900 }
1901
1902 if (ctx.is_directory || ctx.is_git_submodule)
1903 && !path_for_match.is_empty()
1904 && pattern.len() > path_for_match.len()
1905 && pattern.as_bytes().get(path_for_match.len()) == Some(&b'/')
1906 && pattern.starts_with(path_for_match)
1907 && simple_length(pattern) < pattern.len()
1908 {
1909 return true;
1910 }
1911
1912 false
1913}
1914
1915#[must_use]
1917pub fn context_from_mode_octal(mode: &str) -> PathspecMatchContext {
1918 let Ok(bits) = u32::from_str_radix(mode, 8) else {
1919 return PathspecMatchContext::default();
1920 };
1921 context_from_mode_bits(bits)
1922}
1923
1924#[must_use]
1926pub(crate) fn context_from_mode_bits(mode: u32) -> PathspecMatchContext {
1927 let ty = mode & 0o170000;
1928 PathspecMatchContext {
1929 is_directory: ty == 0o040000,
1930 is_git_submodule: ty == 0o160000,
1931 precompose_paths: false,
1932 }
1933}
1934
1935#[must_use]
1941pub(crate) fn matches_ls_tree_pathspec(
1942 spec: &str,
1943 path: &str,
1944 mode: u32,
1945 attr_rules: &[AttrRule],
1946) -> bool {
1947 let (elem_magic, raw_pattern) = parse_element_magic(spec);
1948 let mut magic = combine_magic(elem_magic);
1949 magic.exclude = false;
1950
1951 if magic.literal && magic.glob {
1952 return false;
1953 }
1954
1955 let ctx = context_from_mode_bits(mode);
1956 let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1957
1958 if !magic.attr_requirements.is_empty()
1959 && !attr_requirements_match(
1960 &magic.attr_requirements,
1961 AttrLookup::rules_only(attr_rules),
1962 path,
1963 is_dir_for_attr,
1964 mode,
1965 )
1966 {
1967 return false;
1968 }
1969
1970 let pattern = strip_top_magic(raw_pattern);
1971 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1972 match path_for_match_under_magic_prefix(path, prefix) {
1973 Some(rest) => rest,
1974 None => return false,
1975 }
1976 } else {
1977 path
1978 };
1979
1980 if magic.literal || magic.glob || magic.icase {
1981 return pathspec_matches_tail(pattern, path_for_match, magic);
1982 }
1983
1984 let spec_nfc: Cow<'_, str> = Cow::Borrowed(pattern);
1985 let path_nfc: Cow<'_, str> = Cow::Borrowed(path_for_match);
1986 let pattern = spec_nfc.as_ref();
1987 let path = path_nfc.as_ref();
1988
1989 let trimmed = pattern.strip_prefix("./").unwrap_or(pattern);
1990 if trimmed == "." || trimmed.is_empty() {
1991 return true;
1992 }
1993
1994 let uses_star_or_question = trimmed.contains('*') || trimmed.contains('?');
1995 if !uses_star_or_question {
1996 return ls_tree_literal_match(trimmed, path, ctx);
1997 }
1998
1999 let nwl = simple_length(trimmed);
2000 let flags = 0u32;
2001 if nwl == trimmed.len() {
2002 return wildmatch(trimmed.as_bytes(), path.as_bytes(), flags);
2003 }
2004 let lit = trimmed.as_bytes().get(..nwl).unwrap_or_default();
2005 let path_b = path.as_bytes();
2006 if path_b.len() < nwl {
2007 return false;
2008 }
2009 if &path_b[..nwl] != lit {
2010 return false;
2011 }
2012 let pat_rest = &trimmed[nwl..];
2013 let path_rest = &path[nwl..];
2014 wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), flags)
2015}
2016
2017#[must_use]
2021pub(crate) fn matches_pathspec_for_object(
2022 spec: &str,
2023 path: &str,
2024 mode: u32,
2025 attr_rules: &[AttrRule],
2026) -> bool {
2027 matches_pathspec_for_object_with_attrs(spec, path, mode, AttrLookup::rules_only(attr_rules))
2028}
2029
2030fn matches_pathspec_for_object_with_attrs(
2031 spec: &str,
2032 path: &str,
2033 mode: u32,
2034 attrs: AttrLookup<'_>,
2035) -> bool {
2036 let (elem_magic, raw_pattern) = parse_element_magic(spec);
2037 let mut magic = combine_magic(elem_magic);
2038 magic.exclude = false;
2039
2040 if magic.literal && magic.glob {
2041 return false;
2042 }
2043
2044 let ctx = context_from_mode_bits(mode);
2045 let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
2046
2047 if !magic.attr_requirements.is_empty()
2048 && !attr_requirements_match(&magic.attr_requirements, attrs, path, is_dir_for_attr, mode)
2049 {
2050 return false;
2051 }
2052
2053 let pattern = strip_top_magic(raw_pattern);
2054 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
2055 match path_for_match_under_magic_prefix(path, prefix) {
2056 Some(rest) => rest,
2057 None => return false,
2058 }
2059 } else {
2060 path
2061 };
2062 if magic.literal || magic.glob || magic.icase {
2063 pathspec_matches_tail(pattern, path_for_match, magic)
2064 } else {
2065 matches_pathspec_with_context(pattern, path_for_match, ctx)
2066 }
2067}
2068
2069#[derive(Debug, Clone)]
2071pub struct PathOutsideRepository {
2072 pub elt: String,
2074 pub path: String,
2076 pub work_tree: PathBuf,
2078}
2079
2080impl std::fmt::Display for PathOutsideRepository {
2081 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2082 write!(
2083 f,
2084 "{}: '{}' is outside repository at '{}'",
2085 self.elt,
2086 self.path,
2087 self.work_tree.display()
2088 )
2089 }
2090}
2091
2092impl From<PathOutsideRepository> for PathspecResolveError {
2093 fn from(outside: PathOutsideRepository) -> Self {
2094 Self::OutsideRepository {
2095 elt: outside.elt,
2096 path: outside.path,
2097 work_tree: outside.work_tree.display().to_string(),
2098 }
2099 }
2100}
2101
2102pub fn resolve_magic_pathspec(spec: &str, cwd_prefix: &str) -> Option<String> {
2110 if !spec.starts_with(":(") {
2111 return None;
2112 }
2113 let close_idx = spec.find(')')?;
2114 let magic_prefix = &spec[..=close_idx];
2115 let tail = &spec[close_idx + 1..];
2116 Some(resolve_magic_pathspec_parts(magic_prefix, tail, cwd_prefix))
2117}
2118
2119#[must_use]
2121pub fn is_long_magic_pathspec_token(token: &str) -> bool {
2122 resolve_magic_pathspec(token, "").is_some()
2123}
2124
2125fn resolve_magic_pathspec_parts(magic_prefix: &str, tail: &str, cwd_prefix: &str) -> String {
2126 if has_magic_prefix_token(magic_prefix) {
2127 return format!("{magic_prefix}{tail}");
2128 }
2129
2130 if let Some(rooted_tail) = tail.strip_prefix('/') {
2131 return format!("{magic_prefix}{}", normalize_relative_path_str(rooted_tail));
2132 }
2133
2134 if long_magic_prefix_has_top(magic_prefix) {
2135 return format!("{magic_prefix}{tail}");
2136 }
2137
2138 let combined = if cwd_prefix.is_empty() {
2139 normalize_relative_path_str(tail)
2140 } else {
2141 normalize_relative_path_str(&format!("{cwd_prefix}{tail}"))
2142 };
2143
2144 let cwd_base = normalize_relative_path_str(cwd_prefix.trim_end_matches('/'));
2145 if !cwd_base.is_empty()
2146 && (combined == cwd_base || combined.starts_with(&format!("{cwd_base}/")))
2147 {
2148 let after_base = combined
2149 .strip_prefix(&cwd_base)
2150 .unwrap_or(combined.as_str());
2151 let remainder = after_base.strip_prefix('/').unwrap_or(after_base);
2152 let magic_with_prefix = inject_magic_prefix_token(magic_prefix, &format!("{cwd_base}/"));
2153 return format!("{magic_with_prefix}{remainder}");
2154 }
2155
2156 format!("{magic_prefix}{combined}")
2157}
2158
2159fn has_magic_prefix_token(magic_prefix: &str) -> bool {
2160 let Some(inner) = magic_prefix
2161 .strip_prefix(":(")
2162 .and_then(|s| s.strip_suffix(')'))
2163 else {
2164 return false;
2165 };
2166 inner
2167 .split(',')
2168 .map(str::trim)
2169 .any(|token| token.starts_with("prefix:"))
2170}
2171
2172fn long_magic_prefix_has_top(magic_prefix: &str) -> bool {
2173 let Some(inner) = magic_prefix
2174 .strip_prefix(":(")
2175 .and_then(|s| s.strip_suffix(')'))
2176 else {
2177 return false;
2178 };
2179 inner
2180 .split(',')
2181 .map(str::trim)
2182 .any(|token| token.eq_ignore_ascii_case("top"))
2183}
2184
2185fn inject_magic_prefix_token(magic_prefix: &str, prefix: &str) -> String {
2186 let Some(inner) = magic_prefix
2187 .strip_prefix(":(")
2188 .and_then(|s| s.strip_suffix(')'))
2189 else {
2190 return magic_prefix.to_string();
2191 };
2192 if inner.trim().is_empty() {
2193 format!(":(prefix:{prefix})")
2194 } else {
2195 format!(":({inner},prefix:{prefix})")
2196 }
2197}
2198
2199fn normalize_repo_root_relative_pathspec(pathspec: &str) -> String {
2204 let mut spec = pathspec;
2205 let mut stripped = false;
2206 while let Some(rest) = spec.strip_prefix("./") {
2207 stripped = true;
2208 spec = rest.trim_start_matches('/');
2209 }
2210 if !stripped {
2211 return pathspec.to_owned();
2212 }
2213 if spec.is_empty() {
2214 return ".".to_owned();
2215 }
2216 if spec.starts_with(':') && !literal_pathspecs_enabled() {
2217 return format!(":(literal){spec}");
2218 }
2219 spec.to_owned()
2220}
2221
2222fn normalize_pathspec_separators(pathspec: &str) -> std::borrow::Cow<'_, str> {
2224 #[cfg(windows)]
2225 {
2226 if !pathspec.starts_with(':') && pathspec.contains('\\') {
2227 return std::borrow::Cow::Owned(pathspec.replace('\\', "/"));
2228 }
2229 }
2230 std::borrow::Cow::Borrowed(pathspec)
2231}
2232
2233fn normalize_relative_path_str(path: &str) -> String {
2234 let mut parts: Vec<String> = Vec::new();
2235 for component in std::path::Path::new(path).components() {
2236 match component {
2237 std::path::Component::CurDir => {}
2238 std::path::Component::ParentDir => {
2239 parts.pop();
2240 }
2241 std::path::Component::Normal(seg) => {
2242 parts.push(seg.to_string_lossy().to_string());
2243 }
2244 std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
2245 }
2246 }
2247 parts.join("/")
2248}
2249
2250#[must_use]
2252pub fn pathdiff(cwd: &Path, work_tree: &Path) -> Option<String> {
2253 use crate::git_path::{path_for_disk_compare, path_to_git_slash};
2254
2255 let cwd_cmp = path_for_disk_compare(cwd);
2256 let wt_cmp = path_for_disk_compare(work_tree);
2257
2258 if cwd_cmp == wt_cmp {
2259 return None;
2260 }
2261
2262 let rel = cwd_cmp.strip_prefix(&wt_cmp).ok()?;
2263 let git = path_to_git_slash(rel);
2264 if git.is_empty() {
2265 None
2266 } else {
2267 Some(git)
2268 }
2269}
2270
2271fn prepend_cwd_to_short_exclude_pathspec(spec: &str, cwd: &str) -> Option<String> {
2275 let cwd = cwd.trim_end_matches('/');
2276 if cwd.is_empty() {
2277 return None;
2278 }
2279 let (elem_magic, pattern) = parse_element_magic(spec);
2280 let magic = combine_magic(elem_magic);
2281 if !magic.exclude || magic.top {
2282 return None;
2283 }
2284 if pattern.is_empty() || pattern.starts_with('/') {
2285 return None;
2286 }
2287 if magic.glob || magic.literal || magic.icase || !magic.attr_requirements.is_empty() {
2288 return None;
2289 }
2290 Some(format!(":(exclude,prefix:{cwd}/){pattern}"))
2291}
2292
2293#[must_use]
2295pub(crate) fn pathspec_match_context_with_precompose(
2296 precompose_paths: bool,
2297) -> PathspecMatchContext {
2298 PathspecMatchContext {
2299 precompose_paths,
2300 ..PathspecMatchContext::default()
2301 }
2302}
2303
2304#[must_use]
2306pub fn matches_pathspec_list_precompose(
2307 path: &str,
2308 specs: &[String],
2309 precompose_paths: bool,
2310) -> bool {
2311 matches_pathspec_list_with_context(
2312 path,
2313 specs,
2314 pathspec_match_context_with_precompose(precompose_paths),
2315 )
2316}
2317
2318#[must_use]
2320pub(crate) fn argv_pathspec_preserves_spelling(pathspec: &str) -> bool {
2321 pathspec.starts_with(":(")
2322 || pathspec.starts_with(":/")
2323 || pathspec.starts_with(":!")
2324 || pathspec.starts_with(":^")
2325}
2326
2327#[must_use]
2329pub fn precompose_argv_pathspec(pathspec: &str) -> Cow<'_, str> {
2330 if argv_pathspec_preserves_spelling(pathspec) {
2331 Cow::Borrowed(pathspec)
2332 } else {
2333 precompose_utf8_path(pathspec)
2334 }
2335}
2336
2337#[must_use]
2342pub(crate) fn resolve_pathspec(pathspec: &str, work_tree: &Path, prefix: Option<&str>) -> String {
2343 resolve_pathspec_with_argv_precompose(pathspec, work_tree, prefix, false)
2344}
2345
2346#[must_use]
2348pub fn resolve_pathspec_with_argv_precompose(
2349 pathspec: &str,
2350 work_tree: &Path,
2351 prefix: Option<&str>,
2352 argv_precompose: bool,
2353) -> String {
2354 let pathspec = if argv_precompose {
2355 precompose_argv_pathspec(pathspec)
2356 } else {
2357 Cow::Borrowed(pathspec)
2358 };
2359 resolve_pathspec_impl(pathspec.as_ref(), work_tree, prefix)
2360}
2361
2362fn resolve_pathspec_impl(pathspec: &str, work_tree: &Path, prefix: Option<&str>) -> String {
2363 let pathspec = normalize_pathspec_separators(pathspec);
2364 let pathspec = pathspec.as_ref();
2365 if pathspec == "." {
2368 return match prefix {
2369 Some(p) if !p.is_empty() => p.to_owned(),
2370 _ => ".".to_owned(),
2371 };
2372 }
2373 if pathspec.contains("../") || pathspec.starts_with("../") {
2374 let cwd = match prefix.filter(|p| !p.is_empty()) {
2375 Some(p) => work_tree.join(p),
2376 None => work_tree.to_path_buf(),
2377 };
2378 let abs = cwd.join(pathspec);
2379 let mut parts: Vec<std::ffi::OsString> = Vec::new();
2380 for component in abs.components() {
2381 use std::path::Component;
2382 match component {
2383 Component::ParentDir => {
2384 parts.pop();
2385 }
2386 Component::CurDir => {}
2387 other => parts.push(other.as_os_str().to_os_string()),
2388 }
2389 }
2390 let abs_norm: PathBuf = parts.iter().collect();
2391 if let Some(rel) = crate::git_path::strip_worktree_prefix(&abs_norm, work_tree) {
2392 return rel;
2393 }
2394 }
2395 if Path::new(pathspec).is_absolute() {
2396 let abs = Path::new(pathspec);
2397 if let Some(rel) = crate::git_path::strip_worktree_prefix(abs, work_tree) {
2398 return rel;
2399 }
2400 return pathspec.to_owned();
2401 }
2402
2403 if pathspec.starts_with(':') {
2404 if let Some(p) = prefix {
2405 if !p.is_empty() && !literal_pathspecs_enabled() {
2406 let cwd_ps = format!("{}/", p.trim_end_matches('/'));
2407 if pathspec.starts_with(":(") {
2408 if pathspec_has_top(pathspec) {
2409 return pathspec.to_owned();
2410 }
2411 if let Some(resolved) = resolve_magic_pathspec(pathspec, &cwd_ps) {
2412 return resolved;
2413 }
2414 return pathspec.to_owned();
2415 }
2416 if pathspec_is_exclude(pathspec) {
2417 if let Some(fixed) = prepend_cwd_to_short_exclude_pathspec(pathspec, p) {
2418 return fixed;
2419 }
2420 }
2421 }
2422 }
2423 if let Some(rest) = pathspec.strip_prefix(":/") {
2424 if rest.starts_with('!') || rest.starts_with('^') {
2426 return pathspec.to_owned();
2427 }
2428 return rest.to_owned();
2429 }
2430 if pathspec.starts_with(":(") {
2433 return pathspec.to_owned();
2434 }
2435 return pathspec.to_owned();
2436 }
2437
2438 match prefix {
2439 Some(p) if !p.is_empty() => {
2440 normalize_relative_path_str(&PathBuf::from(p).join(pathspec).to_string_lossy())
2441 }
2442 _ => normalize_repo_root_relative_pathspec(pathspec),
2443 }
2444}
2445
2446pub fn resolve_pathspec_in_worktree(
2451 elt: &str,
2452 pathspec: &str,
2453 work_tree: &Path,
2454 prefix: Option<&str>,
2455) -> Result<String, PathOutsideRepository> {
2456 resolve_pathspec_in_worktree_with_argv_precompose(elt, pathspec, work_tree, prefix, false)
2457}
2458
2459pub(crate) fn resolve_pathspec_in_worktree_with_argv_precompose(
2464 elt: &str,
2465 pathspec: &str,
2466 work_tree: &Path,
2467 prefix: Option<&str>,
2468 argv_precompose: bool,
2469) -> Result<String, PathOutsideRepository> {
2470 let resolved =
2471 resolve_pathspec_with_argv_precompose(pathspec, work_tree, prefix, argv_precompose);
2472 if Path::new(&resolved).is_absolute() {
2473 let wt = work_tree
2474 .canonicalize_simplified()
2475 .unwrap_or_else(|_| work_tree.to_path_buf());
2476 return Err(PathOutsideRepository {
2477 elt: elt.to_string(),
2478 path: resolved,
2479 work_tree: wt,
2480 });
2481 }
2482 Ok(resolved)
2483}
2484
2485#[must_use]
2489#[allow(dead_code)] pub(crate) fn normalize_worktree_file_path(
2491 file_path: &str,
2492 work_tree: &Path,
2493 prefix: Option<&str>,
2494) -> String {
2495 normalize_worktree_file_path_with_git_dir(file_path, work_tree, prefix, None)
2496}
2497
2498#[must_use]
2500pub(crate) fn normalize_worktree_file_path_with_git_dir(
2501 file_path: &str,
2502 work_tree: &Path,
2503 prefix: Option<&str>,
2504 git_dir: Option<&Path>,
2505) -> String {
2506 let argv_precompose = argv_precompose_enabled(git_dir);
2507 let resolved =
2508 resolve_pathspec_with_argv_precompose(file_path, work_tree, prefix, argv_precompose);
2509 if Path::new(&resolved).is_absolute() {
2510 file_path.to_string()
2511 } else {
2512 resolved
2513 }
2514}
2515
2516#[cfg(test)]
2517mod tree_entry_pathspec_tests {
2518 use super::*;
2519
2520 #[test]
2521 fn t6130_bracket_filename_matches_pathspec() {
2522 assert!(matches_pathspec("f[o][o]", "f[o][o]"));
2523 assert!(matches_pathspec(":(glob)f[o][o]", "f[o][o]"));
2524 }
2525
2526 #[test]
2527 fn literal_prefix_and_exact() {
2528 assert!(matches_pathspec("path1", "path1/file1"));
2529 assert!(matches_pathspec_with_context(
2530 "path1/",
2531 "path1/file1",
2532 PathspecMatchContext::default()
2533 ));
2534 assert!(matches_pathspec("file0", "file0"));
2535 assert!(!matches_pathspec("path", "path1/file1"));
2536 }
2537
2538 #[test]
2539 fn dir_prefix_needs_slash_boundary() {
2540 assert!(matches_pathspec("a/b", "a/b/c"));
2541 assert!(!matches_pathspec("a/b", "a/bc"));
2542 assert!(!matches_pathspec("a/b", "a"));
2543 assert!(matches_pathspec(":(literal)dir", "dir/x"));
2544 assert!(!matches_pathspec(":(literal)dir", "dirx"));
2545 assert!(matches_pathspec(":(icase)DIR", "dir/x"));
2546 assert!(!matches_pathspec(":(icase)DIR", "dirx"));
2547 assert!(matches_pathspec("dir/", "dir/x"));
2548 assert!(!matches_pathspec("dir/", "dir"));
2549 }
2550
2551 #[test]
2552 fn sorted_lookup_agrees_with_linear_scan() {
2553 let mut paths: Vec<&str> = vec![
2554 "a",
2555 "a-b",
2556 "a.txt",
2557 "a/b",
2558 "a/b/c.txt",
2559 "a/bc",
2560 "a0",
2561 "b/x[1].txt",
2562 "dir/sub/file",
2563 "dir-sub",
2564 "f[o][o]",
2565 "z",
2566 "caf\u{e9}/menu",
2567 "cafe\u{301}/old",
2568 "\u{212a}.txt",
2569 ];
2570 paths.sort_unstable();
2571 let specs = [
2572 "caf\u{e9}",
2573 "cafe\u{301}/",
2574 "cafe\u{301}/old",
2575 "K.txt",
2576 "\u{212a}.txt",
2577 "a",
2578 "a/",
2579 "a/b",
2580 "a/b/",
2581 "a/bc",
2582 "a-",
2583 "a-b",
2584 "a.txt",
2585 "b",
2586 "b/",
2587 "dir",
2588 "dir/sub",
2589 "dir/sub/",
2590 "dir/su",
2591 "dir-sub",
2592 "f[o][o]",
2593 "*.txt",
2594 "a/*",
2595 ":(glob)**/*.txt",
2596 ":(literal)f[o][o]",
2597 ":(icase)A/B",
2598 ":!a",
2599 ".",
2600 "./a/b",
2601 "",
2602 "z",
2603 "zz",
2604 "/",
2605 "a//",
2606 ];
2607 for ctx in [
2608 PathspecMatchContext::default(),
2609 PathspecMatchContext {
2610 precompose_paths: true,
2611 ..PathspecMatchContext::default()
2612 },
2613 PathspecMatchContext {
2614 is_directory: true,
2615 ..PathspecMatchContext::default()
2616 },
2617 ] {
2618 for spec in specs {
2619 let list = [spec.to_owned()];
2620 let linear = paths
2621 .iter()
2622 .any(|p| matches_pathspec_list_with_context(p, &list, ctx));
2623 assert_eq!(
2624 sorted_paths_match_pathspec(spec, &paths, ctx),
2625 linear,
2626 "spec {spec:?} ctx {ctx:?}"
2627 );
2628 }
2629 }
2630 let empty: [&str; 0] = [];
2631 assert!(!sorted_paths_match_pathspec(
2632 "a",
2633 &empty,
2634 PathspecMatchContext::default()
2635 ));
2636 }
2637
2638 #[test]
2639 fn list_matcher_agrees_with_linear_matching() {
2640 let paths = [
2641 "a",
2642 "a-b",
2643 "a.txt",
2644 "a/",
2645 "a/b",
2646 "a/b/c.txt",
2647 "a/bc",
2648 "a//x",
2649 "a0",
2650 "b/x[1].txt",
2651 "dir/sub/file",
2652 "dir-sub",
2653 "f[o][o]",
2654 "z",
2655 "zz/a",
2656 "é/x",
2657 "e\u{301}/y",
2658 "\u{212a}.txt",
2659 "K.txt",
2660 "",
2661 ];
2662 let lists: &[&[&str]] = &[
2663 &[],
2664 &["a"],
2665 &["a/"],
2666 &["a//"],
2667 &["a/b", "dir/sub/", "z"],
2668 &["a/b/", "a/bc", "dir", "zz"],
2669 &["dir/su", "dir-sub", "f[o][o]"],
2670 &["é", "a-"],
2671 &["e\u{301}", "z"],
2672 &["e\u{301}/y", "K.txt"],
2673 &["\u{212a}.txt"],
2674 &["a", "*.txt"],
2675 &["dir", ":!dir/sub"],
2676 &[":!a"],
2677 &["./a", "z"],
2678 &[".", "a"],
2679 &["", "a"],
2680 &[":(icase)A/B", "z"],
2681 ];
2682 for ctx in [
2683 PathspecMatchContext::default(),
2684 PathspecMatchContext {
2685 precompose_paths: true,
2686 ..PathspecMatchContext::default()
2687 },
2688 PathspecMatchContext {
2689 is_directory: true,
2690 ..PathspecMatchContext::default()
2691 },
2692 ] {
2693 for list in lists {
2694 let specs: Vec<String> = list.iter().map(|s| (*s).to_owned()).collect();
2695 let matcher = PathspecListMatcher::new(&specs, ctx);
2696 for path in paths {
2697 assert_eq!(
2698 matcher.matches(path),
2699 matches_pathspec_list_with_context(path, &specs, ctx),
2700 "path {path:?} specs {specs:?} ctx {ctx:?}"
2701 );
2702 }
2703 }
2704 }
2705 }
2706
2707 #[test]
2708 fn list_matcher_uses_literal_lookup_only_for_plain_lists() {
2709 let ctx = PathspecMatchContext::default();
2710 let plain = vec!["a/b".to_owned(), "dir/".to_owned()];
2711 assert!(PathspecListMatcher::new(&plain, ctx).literals.is_some());
2712 let glob = vec!["a/b".to_owned(), "*.txt".to_owned()];
2713 assert!(PathspecListMatcher::new(&glob, ctx).literals.is_none());
2714 let exclude = vec!["a/b".to_owned(), ":!a/b/c".to_owned()];
2715 assert!(PathspecListMatcher::new(&exclude, ctx).literals.is_none());
2716 assert!(PathspecListMatcher::new(&[], ctx).literals.is_none());
2717 }
2718
2719 #[test]
2720 fn precomposed_literal_specs_keep_fast_lookups() {
2721 let ctx = PathspecMatchContext {
2722 precompose_paths: true,
2723 ..PathspecMatchContext::default()
2724 };
2725 let specs: Vec<String> = (0..100).map(|i| format!("d{i}/file.txt")).collect();
2726 let matcher = PathspecListMatcher::new(&specs, ctx);
2727 assert!(
2728 matcher.literals.is_some(),
2729 "core.precomposeunicode must not force a per-spec scan of every path"
2730 );
2731 assert!(matcher.matches("d42/file.txt"));
2732 assert!(!matcher.matches("d42/other.txt"));
2733 let nfd = vec!["cafe\u{301}".to_owned()];
2734 let matcher = PathspecListMatcher::new(&nfd, ctx);
2735 assert!(matcher.literals.is_some());
2736 assert!(matcher.matches("caf\u{e9}/menu"));
2737 assert!(matcher.matches("cafe\u{301}/menu"));
2738 assert!(exact_literal_pathspec_set(&specs, ctx).is_none());
2739 }
2740
2741 #[test]
2742 fn ls_tree_bracket_in_name_is_literal_prefix() {
2743 assert!(matches_ls_tree_pathspec(
2744 "a[a]",
2745 "a[a]/three",
2746 0o100644,
2747 &[]
2748 ));
2749 assert!(!matches_pathspec_with_context(
2750 "a[a]",
2751 "a[a]/three",
2752 PathspecMatchContext::default()
2753 ));
2754 }
2755
2756 #[test]
2757 fn wildcards_cross_slash_by_default() {
2758 assert!(matches_pathspec("f*", "file0"));
2759 assert!(matches_pathspec("*file1", "path1/file1"));
2760 assert!(matches_pathspec_with_context(
2761 "path1/f*",
2762 "path1",
2763 PathspecMatchContext {
2764 is_directory: true,
2765 ..Default::default()
2766 }
2767 ));
2768 assert!(matches_pathspec("path1/*file1", "path1/file1"));
2769 }
2770
2771 #[test]
2772 fn glob_double_star_txt_at_repo_root() {
2773 assert!(pathspec_matches(":(glob)**/*.txt", "untracked.txt"));
2774 assert!(pathspec_matches(":(glob)**/*.txt", "d/untracked.txt"));
2775 }
2776
2777 #[test]
2778 fn trailing_slash_directory_only() {
2779 assert!(!matches_pathspec_with_context(
2780 "file0/",
2781 "file0",
2782 PathspecMatchContext::default()
2783 ));
2784 assert!(matches_pathspec_with_context(
2785 "file0/",
2786 "file0",
2787 PathspecMatchContext {
2788 is_directory: true,
2789 ..Default::default()
2790 }
2791 ));
2792 assert!(matches_pathspec_with_context(
2793 "submod/",
2794 "submod",
2795 PathspecMatchContext {
2796 is_git_submodule: true,
2797 ..Default::default()
2798 }
2799 ));
2800 }
2801
2802 #[test]
2803 fn exclude_top_short_magic_subtracts_from_positive() {
2804 let specs = vec!["*".to_string(), ":/!sub2".to_string()];
2805 assert!(matches_pathspec_list("sub/file", &specs));
2806 assert!(!matches_pathspec_list("sub2/file", &specs));
2807 assert!(pathspec_exclude_matches(":/!sub2", "sub2/file"));
2808 }
2809}
2810
2811#[cfg(test)]
2812mod exact_literal_pathspec_set_tests {
2813 use super::*;
2814
2815 #[test]
2816 fn exact_literal_pathspec_set_accepts_file_paths() {
2817 let ctx = PathspecMatchContext::default();
2818 let set = exact_literal_pathspec_set(&["large.txt".to_string()], ctx).expect("set");
2819 assert_eq!(set.len(), 1);
2820 assert!(set.contains("large.txt"));
2821 }
2822
2823 #[test]
2824 fn exact_literal_pathspec_set_rejects_directory_trailing_slash() {
2825 let ctx = PathspecMatchContext::default();
2826 assert!(exact_literal_pathspec_set(&["src/".to_string()], ctx).is_none());
2827 }
2828
2829 #[test]
2830 fn exact_literal_pathspec_set_rejects_glob_magic() {
2831 let ctx = PathspecMatchContext::default();
2832 assert!(exact_literal_pathspec_set(&[":(glob)*.rs".to_string()], ctx).is_none());
2833 }
2834}
2835
2836#[cfg(test)]
2837mod pathspec_list_tests {
2838 use super::*;
2839 use crate::crlf::parse_gitattributes_content;
2840
2841 #[test]
2842 fn exclude_removes_paths_matching_icase_positive() {
2843 let specs = vec![
2844 ":(icase)*.txt".to_string(),
2845 ":(exclude)submodule/subsub/*".to_string(),
2846 ];
2847 assert!(path_allowed_by_pathspec_list(&specs, "submodule/g.txt"));
2848 assert!(!path_allowed_by_pathspec_list(
2849 &specs,
2850 "submodule/subsub/e.txt"
2851 ));
2852 }
2853
2854 #[test]
2855 fn prefixed_attr_exclude_removes_matching_child_path() {
2856 let specs = vec![
2857 "sub".to_string(),
2858 ":(exclude,attr:labelB,prefix:sub/)".to_string(),
2859 ];
2860 let exclude_only = vec![":(exclude,attr:labelB,prefix:sub/)".to_string()];
2861 let attrs = parse_gitattributes_content("fileB labelB\n");
2862 assert!(!matches_pathspec_list_for_object(
2863 "sub/fileB",
2864 0o100644,
2865 &attrs,
2866 &specs,
2867 ));
2868 assert!(!matches_pathspec_list_for_object(
2869 "sub/fileB",
2870 0o100644,
2871 &attrs,
2872 &exclude_only,
2873 ));
2874 }
2875}
2876
2877#[cfg(test)]
2878mod resolve_pathspec_tests {
2879 use super::*;
2880
2881 fn wt() -> PathBuf {
2882 PathBuf::from("/tmp/grit-pathspec-worktree")
2883 }
2884
2885 #[test]
2886 fn strips_leading_dot_slash_at_repo_root() {
2887 let wt = wt();
2888 assert_eq!(resolve_pathspec("./-", &wt, None), "-");
2889 assert_eq!(resolve_pathspec("./a/b", &wt, None), "a/b");
2890 assert_eq!(resolve_pathspec("././a", &wt, None), "a");
2891 assert_eq!(resolve_pathspec(".//a", &wt, None), "a");
2892 assert_eq!(resolve_pathspec("./", &wt, None), ".");
2893 assert_eq!(resolve_pathspec(".", &wt, None), ".");
2894 }
2895
2896 #[test]
2897 fn prefixed_cwd_resolution_unchanged() {
2898 let wt = wt();
2899 assert_eq!(
2900 resolve_pathspec("file", &wt, Some("sub/dir")),
2901 "sub/dir/file"
2902 );
2903 assert_eq!(resolve_pathspec(":(glob)*.rs", &wt, None), ":(glob)*.rs");
2904 }
2905
2906 #[test]
2907 fn dot_slash_colon_filename_stays_literal_not_exclude_magic() {
2908 let wt = wt();
2909 let resolved = resolve_pathspec("./:!foo", &wt, None);
2910 assert_eq!(resolved, ":(literal):!foo");
2911 assert!(!pathspec_is_exclude(&resolved));
2912 assert!(pathspec_matches(&resolved, ":!foo"));
2913 assert!(!pathspec_matches(&resolved, "other"));
2914 }
2915
2916 #[test]
2917 fn repository_path_as_literal_pathspec_matches_exact_object_path() {
2918 let magic = ":(literal)foo";
2919 let spec = super::repository_path_as_literal_pathspec(magic);
2920 assert!(matches_pathspec_list(magic, std::slice::from_ref(&spec)));
2921 assert!(!matches_pathspec_list("foo", std::slice::from_ref(&spec)));
2922 }
2923
2924 #[test]
2925 fn resolve_absolute_path_under_worktree() {
2926 let tmp = tempfile::tempdir().expect("tempdir");
2927 let wt = tmp.path().join("repo");
2928 std::fs::create_dir_all(wt.join("d")).expect("mkdir");
2929 let file = wt.join("d/b.txt");
2930 std::fs::write(&file, b"x").expect("write");
2931 let wt = wt.canonicalize_simplified().expect("canonicalize");
2932 let abs = file.canonicalize_simplified().expect("canonicalize file");
2933 let spec = abs.to_string_lossy();
2934 assert_eq!(
2935 resolve_pathspec(&spec, &wt, None),
2936 "d/b.txt",
2937 "absolute forward-slash pathspec"
2938 );
2939 }
2940
2941 #[cfg(windows)]
2942 mod windows {
2943 use super::*;
2944
2945 #[test]
2946 fn resolve_backslash_relative_and_absolute_pathspecs() {
2947 let tmp = tempfile::tempdir().expect("tempdir");
2948 let wt = tmp.path().join("repo");
2949 std::fs::create_dir_all(wt.join("d")).expect("mkdir");
2950 let file_a = wt.join("d/a.txt");
2951 let file_b = wt.join("d/b.txt");
2952 std::fs::write(&file_a, b"a").expect("write");
2953 std::fs::write(&file_b, b"b").expect("write");
2954 let wt = wt.canonicalize_simplified().expect("canonicalize worktree");
2955
2956 assert_eq!(
2957 resolve_pathspec(r"d\a.txt", &wt, None),
2958 "d/a.txt",
2959 "backslash relative pathspec (issue #915 A)"
2960 );
2961
2962 let abs_back = file_b.canonicalize_simplified().expect("canonicalize");
2963 let abs_back_spec = abs_back.display().to_string();
2964 assert_eq!(
2965 resolve_pathspec(&abs_back_spec, &wt, None),
2966 "d/b.txt",
2967 "absolute backslash pathspec (issue #915 B)"
2968 );
2969
2970 let abs_forward = abs_back_spec.replace('\\', "/");
2971 assert_eq!(
2972 resolve_pathspec(&abs_forward, &wt, None),
2973 "d/b.txt",
2974 "absolute forward-slash pathspec (issue #915 C)"
2975 );
2976 }
2977 }
2978
2979 #[test]
2980 fn nfd_pathspec_matches_nfc_index_path_when_precompose_enabled() {
2981 let nfc_path = "caf\u{00e9}.txt";
2982 let nfd_spec = format!("cafe\u{0301}.txt");
2983 assert!(!matches_pathspec_list(&nfc_path, &[nfd_spec.clone()]));
2984 assert!(matches_pathspec_list_precompose(
2985 nfc_path,
2986 &[nfd_spec.clone()],
2987 true
2988 ));
2989 }
2990
2991 #[test]
2992 fn argv_precompose_normalizes_nfd_literal_pathspec() {
2993 let nfd = format!("cafe\u{0301}.txt");
2994 let nfc = precompose_argv_pathspec(&nfd);
2995 assert_eq!(nfc.as_ref(), "caf\u{00e9}.txt");
2996 assert!(argv_pathspec_preserves_spelling(":(glob)*.rs"));
2997 assert_eq!(
2998 precompose_argv_pathspec(":(glob)*.rs").as_ref(),
2999 ":(glob)*.rs"
3000 );
3001 }
3002}
3003
3004#[cfg(test)]
3005mod resolved_pathspecs_tests {
3006 use super::*;
3007 use crate::repo::{InitOptions, Repository};
3008
3009 fn repo_with_tree() -> (tempfile::TempDir, Repository) {
3010 let dir = tempfile::tempdir().expect("tempdir");
3011 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3012 std::fs::write(dir.path().join("top.txt"), b"a").expect("write");
3013 std::fs::create_dir_all(dir.path().join("sub")).expect("mkdir");
3014 std::fs::write(dir.path().join("sub/inner.txt"), b"b").expect("write");
3015 (dir, repo)
3016 }
3017
3018 #[test]
3019 fn resolved_pathspecs_subdir_prefix() {
3020 let (dir, repo) = repo_with_tree();
3021 let sub = dir.path().join("sub");
3022 let resolved =
3023 ResolvedPathspecs::resolve(&repo, &sub, &["inner.txt".to_string()], None).expect("ok");
3024 assert_eq!(resolved.specs, ["sub/inner.txt"]);
3025 assert!(resolved.scoped);
3026 }
3027
3028 #[test]
3029 fn resolved_pathspecs_top_level_magic() {
3030 let (dir, repo) = repo_with_tree();
3031 let resolved =
3032 ResolvedPathspecs::resolve(&repo, dir.path(), &[":/top.txt".to_string()], None)
3033 .expect("ok");
3034 assert_eq!(resolved.specs, ["top.txt"]);
3035 }
3036
3037 #[test]
3038 fn resolved_pathspecs_parent_outside_repo() {
3039 let (dir, repo) = repo_with_tree();
3040 let outside = dir.path().parent().expect("parent").join("outside.txt");
3041 std::fs::write(&outside, b"x").expect("write outside");
3042 let spec = outside.to_string_lossy().into_owned();
3043 let err =
3044 ResolvedPathspecs::resolve(&repo, dir.path(), &[spec], None).expect_err("outside");
3045 assert!(matches!(
3046 err,
3047 PathspecResolveError::OutsideRepository { .. }
3048 ));
3049 }
3050
3051 #[test]
3052 fn resolved_pathspecs_empty_argv() {
3053 let (dir, repo) = repo_with_tree();
3054 let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[String::new()], None)
3055 .expect_err("empty");
3056 assert_eq!(err, PathspecResolveError::EmptyPathspec);
3057 }
3058
3059 #[test]
3060 fn resolved_pathspecs_from_file_lf_and_quoted() {
3061 let (dir, repo) = repo_with_tree();
3062 let input = PathspecInput::new(b"top.txt\n\"sub/inner.txt\"\n".to_vec(), false);
3063 let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
3064 assert_eq!(resolved.specs, ["top.txt", "sub/inner.txt"]);
3065 }
3066
3067 #[test]
3068 fn resolved_pathspecs_from_file_nul() {
3069 let (dir, repo) = repo_with_tree();
3070 let input = PathspecInput::new(b"top.txt\0sub/\0".to_vec(), true);
3071 let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
3072 assert_eq!(resolved.specs, ["top.txt", "sub/"]);
3073 }
3074
3075 #[test]
3076 fn resolved_pathspecs_from_file_nul_preserves_literal_bytes() {
3077 let (dir, repo) = repo_with_tree();
3078 std::fs::write(dir.path().join(" spaced "), b"x").expect("write");
3079 let quoted_dir = dir.path().join("\"double\"");
3080 std::fs::create_dir_all("ed_dir).expect("mkdir");
3081 std::fs::write(quoted_dir.join("f"), b"y").expect("write");
3082 let input = PathspecInput::new(b" spaced \0\"double\"\0".to_vec(), true);
3083 let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
3084 assert_eq!(resolved.sources, [" spaced ", "\"double\""]);
3085 }
3086
3087 #[test]
3088 fn resolved_pathspecs_from_file_blank_line_errors() {
3089 let (dir, repo) = repo_with_tree();
3090 let input = PathspecInput::new(b"\n".to_vec(), false);
3091 let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
3092 assert_eq!(err, PathspecResolveError::EmptyPathspec);
3093 }
3094
3095 #[test]
3096 fn resolved_pathspecs_from_file_nul_empty_record_errors() {
3097 let (dir, repo) = repo_with_tree();
3098 let input = PathspecInput::new(b"a\0\0b".to_vec(), true);
3099 let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
3100 assert_eq!(err, PathspecResolveError::EmptyPathspec);
3101 }
3102
3103 #[test]
3104 fn resolved_pathspecs_from_file_invalid_quote() {
3105 let (dir, repo) = repo_with_tree();
3106 let input = PathspecInput::new(b"\"bad\\z\"\n".to_vec(), false);
3107 let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
3108 assert!(matches!(
3109 err,
3110 PathspecResolveError::FromFile(
3111 PathspecFromFileParseError::InvalidEscapeSequence { .. }
3112 )
3113 ));
3114 let input = PathspecInput::new(b"\"unterminated\n".to_vec(), false);
3115 let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
3116 assert!(matches!(
3117 err,
3118 PathspecResolveError::FromFile(PathspecFromFileParseError::InvalidCStyleQuoting { .. })
3119 ));
3120 }
3121
3122 #[test]
3123 fn resolved_pathspecs_entire_tree_when_unscoped() {
3124 let (dir, repo) = repo_with_tree();
3125 let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], None).expect("ok");
3126 assert!(resolved.is_entire_tree());
3127 assert!(resolved.specs.is_empty());
3128 }
3129}
3130
3131#[cfg(test)]
3132mod resolve_magic_pathspec_tests {
3133 use super::*;
3134
3135 #[test]
3136 fn long_magic_token_predicate() {
3137 assert!(is_long_magic_pathspec_token(":(attr:rust)"));
3138 assert!(!is_long_magic_pathspec_token(":0:file"));
3139 assert!(!is_long_magic_pathspec_token("main"));
3140 }
3141
3142 #[test]
3143 fn top_magic_does_not_inject_cwd_prefix() {
3144 assert_eq!(
3145 resolve_magic_pathspec(":(top)a/g.c", "a/b/").expect("magic"),
3146 ":(top)a/g.c"
3147 );
3148 assert_eq!(
3149 resolve_magic_pathspec(":(top,glob)a/**/*.c", "a/b/").expect("magic"),
3150 ":(top,glob)a/**/*.c"
3151 );
3152 assert_eq!(
3153 resolve_magic_pathspec(":(exclude,top)a/b", "a/b/").expect("magic"),
3154 ":(exclude,top)a/b"
3155 );
3156 }
3157
3158 #[test]
3159 fn non_top_magic_still_injects_prefix_from_subdirectory() {
3160 assert_eq!(
3161 resolve_magic_pathspec(":(glob)**/f.txt", "sub/").expect("magic"),
3162 ":(glob,prefix:sub/)**/f.txt"
3163 );
3164 assert_eq!(
3165 resolve_magic_pathspec(":(exclude)vendor", "sub/").expect("magic"),
3166 ":(exclude,prefix:sub/)vendor"
3167 );
3168 }
3169
3170 #[test]
3171 fn resolve_pathspec_top_from_subdirectory() {
3172 let dir = tempfile::tempdir().expect("tempdir");
3173 let wt = dir.path();
3174 std::fs::create_dir_all(wt.join("a/b")).expect("mkdir");
3175 assert_eq!(
3176 resolve_pathspec(":(top)a/g.c", wt, Some("a/b")),
3177 ":(top)a/g.c"
3178 );
3179 }
3180}
3181
3182#[cfg(test)]
3183mod rev_walk_pathspec_resolution_tests {
3184 use super::*;
3185 use crate::config::environment::{Environment, RepositoryOptions};
3186 use crate::repo::{InitOptions, Repository};
3187
3188 fn repo_from_sub(worktree: &std::path::Path, sub: &std::path::Path) -> Repository {
3189 let mut env = Environment::empty();
3190 env.cwd = sub.to_path_buf();
3191 let mut opts = RepositoryOptions::empty();
3192 opts.environment = env;
3193 Repository::discover(Some(worktree), &opts).expect("discover")
3194 }
3195
3196 #[test]
3197 fn glob_magic_from_subdirectory_gets_prefix_and_matches() {
3198 let dir = tempfile::tempdir().expect("tempdir");
3199 let wt = dir.path();
3200 std::fs::create_dir_all(wt.join("sub")).expect("mkdir");
3201 std::fs::write(wt.join("sub/f.txt"), b"x").expect("write");
3202 Repository::init(wt, &InitOptions::default()).expect("init");
3203 let repo = repo_from_sub(wt, &wt.join("sub"));
3204 let specs = repo
3205 .resolve_rev_walk_pathspecs(&[":(glob)**/f.txt".to_owned()])
3206 .expect("resolve");
3207 assert_eq!(specs, [":(glob,prefix:sub/)**/f.txt"]);
3208 assert!(pathspec_matches(&specs[0], "sub/f.txt"));
3209 assert!(!pathspec_matches(&specs[0], "other/f.txt"));
3210 }
3211
3212 #[test]
3213 fn short_exclude_from_subdirectory_uses_prefix_magic() {
3214 let dir = tempfile::tempdir().expect("tempdir");
3215 let wt = dir.path();
3216 std::fs::create_dir_all(wt.join("sub")).expect("mkdir");
3217 Repository::init(wt, &InitOptions::default()).expect("init");
3218 let repo = repo_from_sub(wt, &wt.join("sub"));
3219 let specs = repo
3220 .resolve_rev_walk_pathspecs(&[":!f.txt".to_owned()])
3221 .expect("resolve");
3222 assert_eq!(specs, [".", ":(exclude,prefix:sub/)f.txt"]);
3223 assert!(matches_pathspec_list("other/g.txt", &specs));
3224 assert!(!matches_pathspec_list("sub/f.txt", &specs));
3225 }
3226
3227 #[test]
3228 fn glob_with_prefix_descends_into_cwd_directory() {
3229 let spec = ":(glob,prefix:sub/)**/f.txt";
3230 assert!(pathspec_wants_descent_into_tree(spec, "sub"));
3231 assert!(pathspec_entry_is_relevant(
3232 "sub/f.txt",
3233 false,
3234 &[spec.to_owned()]
3235 ));
3236 }
3237
3238 #[test]
3239 fn top_magic_from_subdirectory_stays_unprefixed() {
3240 let dir = tempfile::tempdir().expect("tempdir");
3241 let wt = dir.path();
3242 std::fs::create_dir_all(wt.join("a/b")).expect("mkdir");
3243 Repository::init(wt, &InitOptions::default()).expect("init");
3244 let repo = repo_from_sub(wt, &wt.join("a/b"));
3245 let specs = repo
3246 .resolve_rev_walk_pathspecs(&[":(top)a/g.c".to_owned()])
3247 .expect("resolve");
3248 assert_eq!(specs, [":(top)a/g.c"]);
3249 assert!(pathspec_matches(&specs[0], "a/g.c"));
3250 assert!(!pathspec_matches(&specs[0], "a/b/h.c"));
3251 }
3252}