1use std::borrow::Cow;
9
10use crate::crlf::path_has_gitattribute;
11use crate::crlf::AttrRule;
12use crate::error::{Error, Result as LibResult};
13use crate::precompose_config::pathspec_precompose_enabled;
14use crate::unicode_normalization::precompose_utf8_path;
15use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
16
17#[must_use]
20pub fn simple_length(match_str: &str) -> usize {
21 let b = match_str.as_bytes();
22 let mut len = 0usize;
23 for &c in b {
24 if matches!(c, b'*' | b'?' | b'[' | b'\\') {
25 break;
26 }
27 len += 1;
28 }
29 len
30}
31
32#[must_use]
34pub fn has_glob_chars(s: &str) -> bool {
35 simple_length(s) < s.len()
36}
37
38pub fn parse_pathspecs_from_source(data: &[u8], nul_terminated: bool) -> LibResult<Vec<String>> {
47 if nul_terminated {
48 let mut out = Vec::new();
49 for chunk in data.split(|b| *b == 0) {
50 if chunk.is_empty() {
51 continue;
52 }
53 let s = String::from_utf8_lossy(chunk);
54 let t = s.trim();
55 if t.starts_with('"') {
56 return Err(Error::PathError(format!(
57 "pathspec-from-file: line is not NUL terminated: {t}"
58 )));
59 }
60 out.push(t.to_string());
61 }
62 return Ok(out);
63 }
64
65 let text = String::from_utf8_lossy(data);
66 let mut out = Vec::new();
67 for raw in text.split_inclusive('\n') {
68 let line = raw.trim_end_matches('\n').trim_end_matches('\r');
69 if line.is_empty() {
70 continue;
71 }
72 if line.starts_with('"') && line.ends_with('"') && line.len() >= 2 {
73 out.push(unquote_c_style_pathspec_line(line)?);
74 } else {
75 out.push(line.to_string());
76 }
77 }
78 Ok(out)
79}
80
81fn unquote_c_style_pathspec_line(s: &str) -> LibResult<String> {
83 let bytes = s.as_bytes();
84 if bytes.first() != Some(&b'"') || bytes.last() != Some(&b'"') || bytes.len() < 2 {
85 return Err(Error::PathError(format!("invalid C-style quoting: {s}")));
86 }
87
88 let inner = &bytes[1..bytes.len() - 1];
89 let mut out = Vec::with_capacity(inner.len());
90 let mut i = 0;
91 while i < inner.len() {
92 if inner[i] != b'\\' {
93 out.push(inner[i]);
94 i += 1;
95 continue;
96 }
97 i += 1;
98 if i >= inner.len() {
99 return Err(Error::PathError(
100 "invalid escape at end of string".to_string(),
101 ));
102 }
103 match inner[i] {
104 b'\\' => out.push(b'\\'),
105 b'"' => out.push(b'"'),
106 b'a' => out.push(7),
107 b'b' => out.push(8),
108 b'f' => out.push(12),
109 b'n' => out.push(b'\n'),
110 b'r' => out.push(b'\r'),
111 b't' => out.push(b'\t'),
112 b'v' => out.push(11),
113 c if c.is_ascii_digit() => {
114 if i + 2 >= inner.len() {
115 return Err(Error::PathError("truncated octal escape".to_string()));
116 }
117 let oct = std::str::from_utf8(&inner[i..i + 3])
118 .map_err(|_| Error::PathError("invalid octal bytes".to_string()))?;
119 out.push(
120 u8::from_str_radix(oct, 8)
121 .map_err(|_| Error::PathError("invalid octal escape value".to_string()))?,
122 );
123 i += 2;
124 }
125 other => {
126 return Err(Error::PathError(format!(
127 "invalid escape sequence \\{}",
128 char::from(other)
129 )));
130 }
131 }
132 i += 1;
133 }
134 String::from_utf8(out).map_err(|_| Error::PathError("invalid UTF-8 in quoted pathspec".into()))
135}
136
137#[derive(Debug, Clone, Default)]
138struct PathspecMagic {
139 literal: bool,
140 glob: bool,
141 icase: bool,
142 exclude: bool,
143 top: bool,
145 prefix: Option<String>,
146 attr_name: Option<String>,
148}
149
150fn parse_maybe_bool(v: &str) -> Option<bool> {
151 let s = v.trim().to_ascii_lowercase();
152 match s.as_str() {
153 "true" | "yes" | "on" | "1" => Some(true),
154 "false" | "no" | "off" | "0" => Some(false),
155 _ => None,
156 }
157}
158
159fn git_env_bool(key: &str, default: bool) -> bool {
160 match std::env::var(key) {
161 Ok(v) => parse_maybe_bool(&v).unwrap_or(default),
162 Err(_) => default,
163 }
164}
165
166fn literal_global() -> bool {
167 git_env_bool("GIT_LITERAL_PATHSPECS", false)
168}
169
170#[must_use]
172pub fn literal_pathspecs_enabled() -> bool {
173 literal_global()
174}
175
176fn glob_global() -> bool {
177 git_env_bool("GIT_GLOB_PATHSPECS", false)
178}
179
180fn noglob_global() -> bool {
181 git_env_bool("GIT_NOGLOB_PATHSPECS", false)
182}
183
184fn icase_global() -> bool {
185 git_env_bool("GIT_ICASE_PATHSPECS", false)
186}
187
188pub fn validate_global_pathspec_flags() -> Result<(), String> {
192 let lit = literal_global();
193 let glob = glob_global();
194 let noglob = noglob_global();
195 let icase = icase_global();
196
197 if glob && noglob {
198 return Err("global 'glob' and 'noglob' pathspec settings are incompatible".to_string());
199 }
200 if lit && (glob || noglob || icase) {
201 return Err(
202 "global 'literal' pathspec setting is incompatible with all other global pathspec settings"
203 .to_string(),
204 );
205 }
206 Ok(())
207}
208
209fn parse_long_magic(rest_after_paren: &str) -> Option<(PathspecMagic, &str)> {
210 let close = rest_after_paren.find(')')?;
211 let magic_part = &rest_after_paren[..close];
212 let tail = &rest_after_paren[close + 1..];
213 let mut magic = PathspecMagic::default();
214 for raw in magic_part.split(',') {
215 let token = raw.trim();
216 if token.is_empty() {
217 continue;
218 }
219 if let Some(p) = token.strip_prefix("prefix:") {
220 magic.prefix = Some(p.to_string());
221 continue;
222 }
223 if let Some(name) = token.strip_prefix("attr:") {
224 if !name.is_empty() {
225 magic.attr_name = Some(name.to_string());
226 }
227 continue;
228 }
229 if token.eq_ignore_ascii_case("literal") {
230 magic.literal = true;
231 } else if token.eq_ignore_ascii_case("glob") {
232 magic.glob = true;
233 } else if token.eq_ignore_ascii_case("icase") {
234 magic.icase = true;
235 } else if token.eq_ignore_ascii_case("exclude") {
236 magic.exclude = true;
237 } else if token.eq_ignore_ascii_case("top") {
238 magic.top = true;
239 }
240 }
241 Some((magic, tail))
242}
243
244fn parse_short_magic(elem: &str) -> (PathspecMagic, &str) {
246 let bytes = elem.as_bytes();
247 let mut i = 1usize;
248 let mut magic = PathspecMagic::default();
249 while i < bytes.len() && bytes[i] != b':' {
250 let ch = bytes[i];
251 if ch == b'^' {
252 magic.exclude = true;
253 i += 1;
254 continue;
255 }
256 let is_magic = match ch {
257 b'!' => {
258 magic.exclude = true;
259 true
260 }
261 b'/' => {
262 magic.top = true;
263 true
264 } _ => false,
266 };
267 if is_magic {
268 i += 1;
269 continue;
270 }
271 break;
272 }
273 if i < bytes.len() && bytes[i] == b':' {
274 i += 1;
275 }
276 (magic, &elem[i..])
277}
278
279fn parse_element_magic(elem: &str) -> (PathspecMagic, &str) {
281 if !elem.starts_with(':') || literal_global() {
282 return (PathspecMagic::default(), elem);
283 }
284 if let Some(rest) = elem.strip_prefix(":(") {
285 return parse_long_magic(rest).unwrap_or((PathspecMagic::default(), elem));
286 }
287 parse_short_magic(elem)
288}
289
290fn combine_magic(element: PathspecMagic) -> PathspecMagic {
291 let mut m = element;
292 if literal_global() {
293 m.literal = true;
294 }
295 if glob_global() && !m.literal {
296 m.glob = true;
297 }
298 if icase_global() {
299 m.icase = true;
300 }
301 if noglob_global() && !m.glob {
302 m.literal = true;
303 }
304 m
305}
306
307fn strip_top_magic(mut pattern: &str) -> &str {
308 if let Some(r) = pattern.strip_prefix(":/") {
309 pattern = r;
310 }
311 pattern
312}
313
314#[must_use]
319pub fn bloom_lookup_prefix_with_cwd(
320 spec: &str,
321 cwd_from_repo_root: Option<&str>,
322) -> Option<String> {
323 let (elem_magic, raw_pattern) = parse_element_magic(spec);
324 let magic = combine_magic(elem_magic);
325 if magic.exclude || magic.icase {
326 return None;
327 }
328 let pattern = strip_top_magic(raw_pattern);
329 if pattern.is_empty() {
330 return None;
331 }
332 let combined = if magic.top {
333 let cwd = cwd_from_repo_root.unwrap_or("").trim_end_matches('/');
334 if cwd.is_empty() {
335 pattern.to_string()
336 } else {
337 format!("{cwd}/{pattern}")
338 }
339 } else {
340 pattern.to_string()
341 };
342 let pattern = combined.as_str();
343 let mut len = simple_length(pattern);
344 if len != pattern.len() {
345 while len > 0 && pattern.as_bytes()[len - 1] != b'/' {
346 len -= 1;
347 }
348 }
349 while len > 0 && pattern.as_bytes()[len - 1] == b'/' {
350 len -= 1;
351 }
352 if len == 0 {
353 return None;
354 }
355 Some(combined[..len].to_string())
356}
357
358#[must_use]
359pub fn bloom_lookup_prefix(spec: &str) -> Option<String> {
360 bloom_lookup_prefix_with_cwd(spec, None)
361}
362
363#[must_use]
365pub fn pathspecs_allow_bloom(specs: &[String]) -> bool {
366 specs.iter().all(|s| {
367 !s.is_empty() && !pathspec_is_exclude(s) && bloom_lookup_prefix_with_cwd(s, None).is_some()
368 })
369}
370
371#[must_use]
376pub fn path_allowed_by_pathspec_list(specs: &[String], path: &str) -> bool {
377 let mut has_positive = false;
378 let mut positive_match = false;
379 for s in specs {
380 let (elem, raw_pattern) = parse_element_magic(s);
381 let magic = combine_magic(elem);
382 if magic.exclude {
383 if path_matches_pathspec_tail(raw_pattern, path, magic) {
384 return false;
385 }
386 continue;
387 }
388 has_positive = true;
389 if pathspec_matches(s, path) {
390 positive_match = true;
391 }
392 }
393 !has_positive || positive_match
394}
395
396#[must_use]
398pub fn pathspec_contributes_match(spec: &str, path: &str) -> bool {
399 pathspec_matches(spec, path) || pathspec_exclude_matches(spec, path)
400}
401
402fn path_matches_pathspec_tail(raw_pattern: &str, path: &str, magic: PathspecMagic) -> bool {
403 if magic.literal && magic.glob {
404 return false;
405 }
406 let pattern = strip_top_magic(raw_pattern);
407 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
408 if !path.starts_with(prefix) {
409 return false;
410 }
411 &path[prefix.len()..]
412 } else {
413 path
414 };
415 pathspec_matches_tail(pattern, path_for_match, magic)
416}
417
418#[must_use]
423pub fn pathspec_matches(spec: &str, path: &str) -> bool {
424 matches_pathspec(spec, path)
425}
426
427#[must_use]
429pub fn pathspec_is_exclude(spec: &str) -> bool {
430 let (elem_magic, _) = parse_element_magic(spec);
431 combine_magic(elem_magic).exclude
432}
433
434#[must_use]
439pub fn pathspec_wants_descent_into_tree(spec: &str, full_name: &str) -> bool {
440 if pathspec_is_exclude(spec) {
441 return false;
442 }
443 let (elem_magic, raw_pattern) = parse_element_magic(spec);
444 let magic = combine_magic(elem_magic);
445 if magic.exclude {
446 return false;
447 }
448 let pattern = strip_top_magic(raw_pattern);
449 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
450 if pattern.is_empty() || pattern == "." {
451 return true;
452 }
453 let dir_prefix = format!("{full_name}/");
454 if pattern.starts_with(&dir_prefix) {
455 return true;
456 }
457 let probe = format!("{full_name}/.__grit_ls_tree_probe__");
458 matches_ls_tree_pathspec(spec, &probe, 0o100644, &[])
459}
460
461#[must_use]
464pub fn matches_pathspec_set_for_object_ls_tree(
465 specs: &[String],
466 path: &str,
467 mode: u32,
468 attr_rules: &[AttrRule],
469) -> bool {
470 if specs.is_empty() {
471 return true;
472 }
473 let mut positives: Vec<&str> = Vec::new();
474 let mut excludes: Vec<&str> = Vec::new();
475 for s in specs {
476 if pathspec_is_exclude(s) {
477 excludes.push(s.as_str());
478 } else {
479 positives.push(s.as_str());
480 }
481 }
482 let positive_ok = if positives.is_empty() {
483 true
484 } else {
485 positives
486 .iter()
487 .any(|s| matches_ls_tree_pathspec(s, path, mode, attr_rules))
488 };
489 if !positive_ok {
490 return false;
491 }
492 for ex in excludes {
493 if matches_ls_tree_pathspec(ex, path, mode, attr_rules) {
494 return false;
495 }
496 }
497 true
498}
499
500#[must_use]
503pub fn matches_pathspec_set_for_object(
504 specs: &[String],
505 path: &str,
506 mode: u32,
507 attr_rules: &[AttrRule],
508) -> bool {
509 if specs.is_empty() {
510 return true;
511 }
512 let mut positives: Vec<&str> = Vec::new();
513 let mut excludes: Vec<&str> = Vec::new();
514 for s in specs {
515 if pathspec_is_exclude(s) {
516 excludes.push(s.as_str());
517 } else {
518 positives.push(s.as_str());
519 }
520 }
521 let positive_ok = if positives.is_empty() {
522 true
523 } else {
524 positives
525 .iter()
526 .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
527 };
528 if !positive_ok {
529 return false;
530 }
531 for ex in excludes {
532 if matches_pathspec_for_object(ex, path, mode, attr_rules) {
533 return false;
534 }
535 }
536 true
537}
538
539#[must_use]
541pub fn pathspec_has_top(spec: &str) -> bool {
542 let (elem_magic, _) = parse_element_magic(spec);
543 combine_magic(elem_magic).top
544}
545
546fn pathspec_match_one_positive(path: &str, magic: PathspecMagic, raw_pattern: &str) -> bool {
547 if magic.literal && magic.glob {
548 return false;
549 }
550 let pattern = strip_top_magic(raw_pattern);
551 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
552 if !path.starts_with(prefix) {
553 return false;
554 }
555 &path[prefix.len()..]
556 } else {
557 path
558 };
559 pathspec_matches_tail(pattern, path_for_match, magic)
560}
561
562fn matches_pathspec_element_with_context(
563 spec: &str,
564 path: &str,
565 ctx: PathspecMatchContext,
566) -> bool {
567 let (elem_magic, raw_pattern) = parse_element_magic(spec);
568 let magic = combine_magic(elem_magic);
569 if magic.exclude {
570 return false;
571 }
572 if magic.literal && magic.glob {
573 return false;
574 }
575 if magic.attr_name.is_some() {
576 return pathspec_matches(spec, path);
577 }
578 if magic.literal || magic.glob || magic.icase {
579 return pathspec_matches(spec, path);
580 }
581 let pattern = strip_top_magic(raw_pattern);
582 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
583 if !path.starts_with(prefix) {
584 return false;
585 }
586 &path[prefix.len()..]
587 } else {
588 path
589 };
590 matches_pathspec_with_context(pattern, path_for_match, ctx)
591}
592
593fn pathspec_exclude_element_matches_with_context(
594 spec: &str,
595 path: &str,
596 ctx: PathspecMatchContext,
597) -> bool {
598 let (elem_magic, raw_pattern) = parse_element_magic(spec);
599 let mut magic = combine_magic(elem_magic);
600 if !magic.exclude {
601 return false;
602 }
603 magic.exclude = false;
604 if magic.literal && magic.glob {
605 return false;
606 }
607 if magic.attr_name.is_some() {
608 return false;
611 }
612 if magic.literal || magic.glob || magic.icase {
613 return pathspec_match_one_positive(path, magic, raw_pattern);
614 }
615 let pattern = strip_top_magic(raw_pattern);
616 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
617 if !path.starts_with(prefix) {
618 return false;
619 }
620 &path[prefix.len()..]
621 } else {
622 path
623 };
624 matches_pathspec_with_context(pattern, path_for_match, ctx)
625}
626
627#[must_use]
630pub fn pathspec_exclude_matches(spec: &str, path: &str) -> bool {
631 pathspec_exclude_element_matches_with_context(spec, path, PathspecMatchContext::default())
632}
633
634#[must_use]
639pub fn extend_pathspec_list_implicit_cwd(
640 specs: &[String],
641 cwd_from_repo_root: Option<&str>,
642) -> Vec<String> {
643 if specs.is_empty() {
644 return specs.to_vec();
645 }
646 if !specs.iter().all(|s| pathspec_is_exclude(s)) {
647 return specs.to_vec();
648 }
649 let any_top = specs.iter().any(|s| pathspec_has_top(s));
650 if any_top {
651 return specs.to_vec();
652 }
653 let Some(cwd) = cwd_from_repo_root.map(str::trim).filter(|s| !s.is_empty()) else {
654 return specs.to_vec();
655 };
656 let cwd = cwd.trim_end_matches('/');
657 if cwd.is_empty() {
658 return specs.to_vec();
659 }
660 let mut out = Vec::with_capacity(specs.len() + 1);
661 out.push(format!("{cwd}/"));
662 out.extend_from_slice(specs);
663 out
664}
665
666#[must_use]
670pub fn matches_pathspec_list(path: &str, specs: &[String]) -> bool {
671 matches_pathspec_list_with_context(path, specs, PathspecMatchContext::default())
672}
673
674#[must_use]
676pub fn matches_pathspec_list_with_context(
677 path: &str,
678 specs: &[String],
679 ctx: PathspecMatchContext,
680) -> bool {
681 if specs.is_empty() {
682 return true;
683 }
684 let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
685 let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
686 let positive = if positive_specs.is_empty() {
687 true
688 } else {
689 positive_specs
690 .iter()
691 .any(|s| matches_pathspec_element_with_context(s, path, ctx))
692 };
693 if !positive {
694 return false;
695 }
696 if !has_exclude {
697 return true;
698 }
699 let excluded = specs.iter().any(|s| {
700 pathspec_is_exclude(s) && pathspec_exclude_element_matches_with_context(s, path, ctx)
701 });
702 !excluded
703}
704
705#[must_use]
707pub fn matches_pathspec_list_for_object(
708 path: &str,
709 mode: u32,
710 attr_rules: &[AttrRule],
711 specs: &[String],
712) -> bool {
713 if specs.is_empty() {
714 return true;
715 }
716 let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
717 let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
718 let positive = if positive_specs.is_empty() {
719 true
720 } else {
721 positive_specs
722 .iter()
723 .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
724 };
725 if !positive {
726 return false;
727 }
728 if !has_exclude {
729 return true;
730 }
731 let excluded = specs.iter().any(|s| {
732 pathspec_is_exclude(s) && matches_pathspec_exclude_for_object(s, path, mode, attr_rules)
733 });
734 !excluded
735}
736
737fn matches_pathspec_exclude_for_object(
738 spec: &str,
739 path: &str,
740 mode: u32,
741 attr_rules: &[AttrRule],
742) -> bool {
743 let (elem_magic, raw_pattern) = parse_element_magic(spec);
744 let mut magic = combine_magic(elem_magic);
745 if !magic.exclude {
746 return false;
747 }
748 magic.exclude = false;
749 if magic.literal && magic.glob {
750 return false;
751 }
752 let ctx = context_from_mode_bits(mode);
753 let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
754 if let Some(ref attr) = magic.attr_name {
755 if !path_has_gitattribute(attr_rules, path, is_dir_for_attr, attr) {
756 return false;
757 }
758 }
759 let pattern = strip_top_magic(raw_pattern);
760 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
761 if !path.starts_with(prefix) {
762 return false;
763 }
764 &path[prefix.len()..]
765 } else {
766 path
767 };
768 if magic.literal || magic.glob || magic.icase {
769 pathspec_matches_tail(pattern, path_for_match, magic)
770 } else {
771 matches_pathspec_with_context(pattern, path_for_match, ctx)
772 }
773}
774
775fn pathspec_matches_tail(pattern: &str, path: &str, magic: PathspecMagic) -> bool {
776 if pattern.is_empty() {
777 return true;
778 }
779
780 let flags = if magic.icase { WM_CASEFOLD } else { 0 };
781
782 if magic.literal {
783 return literal_prefix_match(pattern, path);
784 }
785
786 let wm_flags = if magic.glob {
787 flags | WM_PATHNAME
788 } else {
789 flags
790 };
791
792 let pattern_bytes = pattern.as_bytes();
793 let path_bytes = path.as_bytes();
794 let simple = simple_length(pattern);
795
796 if ps_str_eq(pattern, path, magic.icase) {
800 return true;
801 }
802 if simple == pattern.len() {
803 if let Some(prefix) = pattern.strip_suffix('/') {
804 if ps_str_eq(prefix, path, magic.icase) {
805 return true;
806 }
807 let prefix_slash = format!("{prefix}/");
808 if path_starts_with(path, &prefix_slash, magic.icase) {
809 return true;
810 }
811 } else {
812 let prefix_slash = format!("{pattern}/");
813 if path_starts_with(path, &prefix_slash, magic.icase) {
814 return true;
815 }
816 }
817 }
818
819 if magic.glob && !path.contains('/') && pattern.starts_with("**/") {
821 if wildmatch(pattern_bytes, path_bytes, wm_flags) {
822 return true;
823 }
824 if let Some(suffix) = pattern.strip_prefix("**/") {
825 if wildmatch(suffix.as_bytes(), path_bytes, wm_flags) {
826 return true;
827 }
828 }
829 }
830
831 if simple < pattern.len() {
833 if path_bytes.len() < simple {
834 return false;
835 }
836 let path_lit = &path_bytes[..simple];
837 let pat_lit = &pattern_bytes[..simple];
838 let same = if magic.icase {
839 path_lit.eq_ignore_ascii_case(pat_lit)
840 } else {
841 path_lit == pat_lit
842 };
843 if !same {
844 return false;
845 }
846 let pat_rest = &pattern[simple..];
847 let path_rest = &path[simple..];
848 return wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), wm_flags);
849 }
850
851 ps_str_eq(pattern, path, magic.icase)
852 || path_starts_with(path, &format!("{pattern}/"), magic.icase)
853}
854
855fn ps_str_eq(a: &str, b: &str, icase: bool) -> bool {
856 if icase {
857 a.eq_ignore_ascii_case(b)
858 } else {
859 a == b
860 }
861}
862
863fn path_starts_with(path: &str, prefix: &str, icase: bool) -> bool {
864 if icase {
865 path.get(..prefix.len())
866 .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
867 } else {
868 path.starts_with(prefix)
869 }
870}
871
872fn literal_prefix_match(pattern: &str, path: &str) -> bool {
873 if let Some(prefix) = pattern.strip_suffix('/') {
874 return path == prefix || path.starts_with(&format!("{prefix}/"));
875 }
876 path == pattern || path.starts_with(&format!("{pattern}/"))
877}
878
879fn ls_tree_literal_match(pattern: &str, path: &str, ctx: PathspecMatchContext) -> bool {
881 if let Some(prefix) = pattern.strip_suffix('/') {
882 if path.starts_with(&format!("{prefix}/")) {
883 return true;
884 }
885 if path == prefix {
886 return ctx.is_directory || ctx.is_git_submodule;
887 }
888 return false;
889 }
890 path == pattern || path.starts_with(&format!("{pattern}/"))
891}
892
893#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
898pub struct PathspecMatchContext {
899 pub is_directory: bool,
901 pub is_git_submodule: bool,
903}
904
905#[must_use]
911pub fn matches_pathspec(spec: &str, path: &str) -> bool {
912 matches_pathspec_with_context(spec, path, PathspecMatchContext::default())
913}
914
915#[must_use]
919pub fn matches_pathspec_with_context(spec: &str, path: &str, ctx: PathspecMatchContext) -> bool {
920 let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
921 precompose_utf8_path(spec)
922 } else {
923 Cow::Borrowed(spec)
924 };
925 let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
926 precompose_utf8_path(path)
927 } else {
928 Cow::Borrowed(path)
929 };
930 let spec = spec_nfc.as_ref();
931 let path = path_nfc.as_ref();
932
933 let trimmed = spec.strip_prefix("./").unwrap_or(spec);
934 if trimmed == "." || trimmed.is_empty() {
935 return true;
936 }
937
938 let (elem_magic, raw_pattern) = parse_element_magic(trimmed);
939 let magic = combine_magic(elem_magic);
940
941 if magic.literal && magic.glob {
942 return false;
943 }
944 if magic.exclude {
945 return false;
946 }
947
948 let pattern = strip_top_magic(raw_pattern);
949 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
950 if !path.starts_with(prefix) {
951 return false;
952 }
953 &path[prefix.len()..]
954 } else {
955 path
956 };
957
958 if magic.literal {
959 if let Some(prefix) = pattern.strip_suffix('/') {
960 if path_for_match.starts_with(&format!("{prefix}/")) {
961 return true;
962 }
963 if path_for_match == prefix {
964 return ctx.is_directory || ctx.is_git_submodule;
965 }
966 return false;
967 }
968 return path_for_match == pattern || path_for_match.starts_with(&format!("{pattern}/"));
969 }
970
971 if let Some(prefix) = pattern.strip_suffix('/') {
973 if simple_length(pattern) == pattern.len() {
974 if path_for_match.starts_with(&format!("{prefix}/")) {
975 return true;
976 }
977 if path_for_match == prefix {
978 return ctx.is_directory || ctx.is_git_submodule;
979 }
980 return false;
981 }
982 }
983
984 if pathspec_matches_tail(pattern, path_for_match, magic) {
985 return true;
986 }
987
988 if (ctx.is_directory || ctx.is_git_submodule)
989 && !path_for_match.is_empty()
990 && pattern.len() > path_for_match.len()
991 && pattern.as_bytes().get(path_for_match.len()) == Some(&b'/')
992 && pattern.starts_with(path_for_match)
993 && simple_length(pattern) < pattern.len()
994 {
995 return true;
996 }
997
998 false
999}
1000
1001#[must_use]
1003pub fn context_from_mode_octal(mode: &str) -> PathspecMatchContext {
1004 let Ok(bits) = u32::from_str_radix(mode, 8) else {
1005 return PathspecMatchContext::default();
1006 };
1007 context_from_mode_bits(bits)
1008}
1009
1010#[must_use]
1012pub fn context_from_mode_bits(mode: u32) -> PathspecMatchContext {
1013 let ty = mode & 0o170000;
1014 PathspecMatchContext {
1015 is_directory: ty == 0o040000,
1016 is_git_submodule: ty == 0o160000,
1017 }
1018}
1019
1020#[must_use]
1026pub fn matches_ls_tree_pathspec(
1027 spec: &str,
1028 path: &str,
1029 mode: u32,
1030 attr_rules: &[AttrRule],
1031) -> bool {
1032 let (elem_magic, raw_pattern) = parse_element_magic(spec);
1033 let mut magic = combine_magic(elem_magic);
1034 magic.exclude = false;
1035
1036 if magic.literal && magic.glob {
1037 return false;
1038 }
1039
1040 let ctx = context_from_mode_bits(mode);
1041 let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1042
1043 if let Some(ref attr) = magic.attr_name {
1044 if !path_has_gitattribute(attr_rules, path, is_dir_for_attr, attr) {
1045 return false;
1046 }
1047 }
1048
1049 let pattern = strip_top_magic(raw_pattern);
1050 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1051 if !path.starts_with(prefix) {
1052 return false;
1053 }
1054 &path[prefix.len()..]
1055 } else {
1056 path
1057 };
1058
1059 if magic.literal || magic.glob || magic.icase {
1060 return pathspec_matches_tail(pattern, path_for_match, magic);
1061 }
1062
1063 let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1064 precompose_utf8_path(pattern)
1065 } else {
1066 Cow::Borrowed(pattern)
1067 };
1068 let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1069 precompose_utf8_path(path_for_match)
1070 } else {
1071 Cow::Borrowed(path_for_match)
1072 };
1073 let pattern = spec_nfc.as_ref();
1074 let path = path_nfc.as_ref();
1075
1076 let trimmed = pattern.strip_prefix("./").unwrap_or(pattern);
1077 if trimmed == "." || trimmed.is_empty() {
1078 return true;
1079 }
1080
1081 let uses_star_or_question = trimmed.contains('*') || trimmed.contains('?');
1082 if !uses_star_or_question {
1083 return ls_tree_literal_match(trimmed, path, ctx);
1084 }
1085
1086 let nwl = simple_length(trimmed);
1087 let flags = 0u32;
1088 if nwl == trimmed.len() {
1089 return wildmatch(trimmed.as_bytes(), path.as_bytes(), flags);
1090 }
1091 let lit = trimmed.as_bytes().get(..nwl).unwrap_or_default();
1092 let path_b = path.as_bytes();
1093 if path_b.len() < nwl {
1094 return false;
1095 }
1096 if &path_b[..nwl] != lit {
1097 return false;
1098 }
1099 let pat_rest = &trimmed[nwl..];
1100 let path_rest = &path[nwl..];
1101 wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), flags)
1102}
1103
1104#[must_use]
1109pub fn matches_pathspec_for_object(
1110 spec: &str,
1111 path: &str,
1112 mode: u32,
1113 attr_rules: &[AttrRule],
1114) -> bool {
1115 let (elem_magic, raw_pattern) = parse_element_magic(spec);
1116 let mut magic = combine_magic(elem_magic);
1117 magic.exclude = false;
1118
1119 if magic.literal && magic.glob {
1120 return false;
1121 }
1122
1123 let ctx = context_from_mode_bits(mode);
1124 let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1125
1126 if let Some(ref attr) = magic.attr_name {
1127 if !path_has_gitattribute(attr_rules, path, is_dir_for_attr, attr) {
1128 return false;
1129 }
1130 }
1131
1132 let pattern = strip_top_magic(raw_pattern);
1133 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1134 if !path.starts_with(prefix) {
1135 return false;
1136 }
1137 &path[prefix.len()..]
1138 } else {
1139 path
1140 };
1141 if magic.literal || magic.glob || magic.icase {
1142 pathspec_matches_tail(pattern, path_for_match, magic)
1143 } else {
1144 matches_pathspec_with_context(pattern, path_for_match, ctx)
1145 }
1146}
1147
1148#[must_use]
1151pub fn wildmatch_flags_icase_glob(icase: bool, glob: bool) -> u32 {
1152 let mut f = if glob { WM_PATHNAME } else { 0 };
1153 if icase {
1154 f |= WM_CASEFOLD;
1155 }
1156 f
1157}
1158
1159#[cfg(test)]
1160mod tree_entry_pathspec_tests {
1161 use super::*;
1162
1163 #[test]
1164 fn t6130_bracket_filename_matches_pathspec() {
1165 assert!(matches_pathspec("f[o][o]", "f[o][o]"));
1166 assert!(matches_pathspec(":(glob)f[o][o]", "f[o][o]"));
1167 }
1168
1169 #[test]
1170 fn literal_prefix_and_exact() {
1171 assert!(matches_pathspec("path1", "path1/file1"));
1172 assert!(matches_pathspec_with_context(
1173 "path1/",
1174 "path1/file1",
1175 PathspecMatchContext::default()
1176 ));
1177 assert!(matches_pathspec("file0", "file0"));
1178 assert!(!matches_pathspec("path", "path1/file1"));
1179 }
1180
1181 #[test]
1182 fn ls_tree_bracket_in_name_is_literal_prefix() {
1183 assert!(matches_ls_tree_pathspec(
1184 "a[a]",
1185 "a[a]/three",
1186 0o100644,
1187 &[]
1188 ));
1189 assert!(!matches_pathspec_with_context(
1190 "a[a]",
1191 "a[a]/three",
1192 PathspecMatchContext::default()
1193 ));
1194 }
1195
1196 #[test]
1197 fn wildcards_cross_slash_by_default() {
1198 assert!(matches_pathspec("f*", "file0"));
1199 assert!(matches_pathspec("*file1", "path1/file1"));
1200 assert!(matches_pathspec_with_context(
1201 "path1/f*",
1202 "path1",
1203 PathspecMatchContext {
1204 is_directory: true,
1205 ..Default::default()
1206 }
1207 ));
1208 assert!(matches_pathspec("path1/*file1", "path1/file1"));
1209 }
1210
1211 #[test]
1212 fn glob_double_star_txt_at_repo_root() {
1213 assert!(pathspec_matches(":(glob)**/*.txt", "untracked.txt"));
1214 assert!(pathspec_matches(":(glob)**/*.txt", "d/untracked.txt"));
1215 }
1216
1217 #[test]
1218 fn trailing_slash_directory_only() {
1219 assert!(!matches_pathspec_with_context(
1220 "file0/",
1221 "file0",
1222 PathspecMatchContext::default()
1223 ));
1224 assert!(matches_pathspec_with_context(
1225 "file0/",
1226 "file0",
1227 PathspecMatchContext {
1228 is_directory: true,
1229 ..Default::default()
1230 }
1231 ));
1232 assert!(matches_pathspec_with_context(
1233 "submod/",
1234 "submod",
1235 PathspecMatchContext {
1236 is_git_submodule: true,
1237 ..Default::default()
1238 }
1239 ));
1240 }
1241
1242 #[test]
1243 fn exclude_top_short_magic_subtracts_from_positive() {
1244 let specs = vec!["*".to_string(), ":/!sub2".to_string()];
1245 assert!(matches_pathspec_list("sub/file", &specs));
1246 assert!(!matches_pathspec_list("sub2/file", &specs));
1247 assert!(pathspec_exclude_matches(":/!sub2", "sub2/file"));
1248 }
1249}
1250
1251#[cfg(test)]
1252mod pathspec_list_tests {
1253 use super::*;
1254
1255 #[test]
1256 fn exclude_removes_paths_matching_icase_positive() {
1257 let specs = vec![
1258 ":(icase)*.txt".to_string(),
1259 ":(exclude)submodule/subsub/*".to_string(),
1260 ];
1261 assert!(path_allowed_by_pathspec_list(&specs, "submodule/g.txt"));
1262 assert!(!path_allowed_by_pathspec_list(
1263 &specs,
1264 "submodule/subsub/e.txt"
1265 ));
1266 }
1267}