1use std::borrow::Cow;
9
10use crate::crlf::path_gitattribute_value;
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_requirements: Vec<AttrRequirement>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151enum AttrRequirement {
152 Set(String),
153 Unset(String),
154 Unspecified(String),
155 Value(String, String),
156}
157
158impl AttrRequirement {
159 fn name(&self) -> &str {
160 match self {
161 AttrRequirement::Set(name)
162 | AttrRequirement::Unset(name)
163 | AttrRequirement::Unspecified(name)
164 | AttrRequirement::Value(name, _) => name,
165 }
166 }
167}
168
169fn parse_maybe_bool(v: &str) -> Option<bool> {
170 let s = v.trim().to_ascii_lowercase();
171 match s.as_str() {
172 "true" | "yes" | "on" | "1" => Some(true),
173 "false" | "no" | "off" | "0" => Some(false),
174 _ => None,
175 }
176}
177
178fn git_env_bool(key: &str, default: bool) -> bool {
179 match std::env::var(key) {
180 Ok(v) => parse_maybe_bool(&v).unwrap_or(default),
181 Err(_) => default,
182 }
183}
184
185fn literal_global() -> bool {
186 git_env_bool("GIT_LITERAL_PATHSPECS", false)
187}
188
189#[must_use]
191pub fn literal_pathspecs_enabled() -> bool {
192 literal_global()
193}
194
195fn glob_global() -> bool {
196 git_env_bool("GIT_GLOB_PATHSPECS", false)
197}
198
199fn noglob_global() -> bool {
200 git_env_bool("GIT_NOGLOB_PATHSPECS", false)
201}
202
203fn icase_global() -> bool {
204 git_env_bool("GIT_ICASE_PATHSPECS", false)
205}
206
207pub fn validate_global_pathspec_flags() -> Result<(), String> {
211 let lit = literal_global();
212 let glob = glob_global();
213 let noglob = noglob_global();
214 let icase = icase_global();
215
216 if glob && noglob {
217 return Err("global 'glob' and 'noglob' pathspec settings are incompatible".to_string());
218 }
219 if lit && (glob || noglob || icase) {
220 return Err(
221 "global 'literal' pathspec setting is incompatible with all other global pathspec settings"
222 .to_string(),
223 );
224 }
225 Ok(())
226}
227
228fn is_valid_attr_name(name: &str) -> bool {
229 !name.is_empty()
230 && name
231 .bytes()
232 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
233}
234
235fn split_attr_expr(expr: &str) -> Result<Vec<String>, String> {
236 let mut parts = Vec::new();
237 let mut cur = String::new();
238 let mut in_value = false;
239 let mut escaped = false;
240
241 for ch in expr.chars() {
242 if escaped {
243 if ch.is_ascii_whitespace() {
244 return Err(
245 "Escape character '\\' not allowed as last character in attr value".to_string(),
246 );
247 }
248 if ch != ',' {
249 return Err("Escape character '\\' not allowed for value matching".to_string());
250 }
251 cur.push(ch);
252 escaped = false;
253 continue;
254 }
255 if in_value && ch == '\\' {
256 escaped = true;
257 continue;
258 }
259 if ch == '=' {
260 in_value = true;
261 cur.push(ch);
262 continue;
263 }
264 if ch.is_ascii_whitespace() {
265 if !cur.is_empty() {
266 parts.push(cur);
267 cur = String::new();
268 }
269 in_value = false;
270 continue;
271 }
272 cur.push(ch);
273 }
274
275 if escaped {
276 return Err(
277 "Escape character '\\' not allowed as last character in attr value".to_string(),
278 );
279 }
280 if !cur.is_empty() {
281 parts.push(cur);
282 }
283 Ok(parts)
284}
285
286fn parse_attr_requirements(expr: &str) -> Result<Vec<AttrRequirement>, String> {
287 if expr.trim().is_empty() {
288 return Err("empty attr magic is invalid".to_string());
289 }
290 let mut out = Vec::new();
291 for token in split_attr_expr(expr)? {
292 if let Some(name) = token.strip_prefix('-') {
293 if name.contains('=') {
294 return Err("invalid attribute name".to_string());
295 }
296 if !is_valid_attr_name(name) {
297 return Err(format!("{name} is not a valid attribute name"));
298 }
299 out.push(AttrRequirement::Unset(name.to_string()));
300 } else if let Some(name) = token.strip_prefix('!') {
301 if name.contains('=') {
302 return Err("invalid attribute name".to_string());
303 }
304 if !is_valid_attr_name(name) {
305 return Err(format!("{name} is not a valid attribute name"));
306 }
307 out.push(AttrRequirement::Unspecified(name.to_string()));
308 } else if let Some((name, value)) = token.split_once('=') {
309 if !is_valid_attr_name(name) {
310 return Err(format!("{name} is not a valid attribute name"));
311 }
312 if value.is_empty() {
313 return Err("empty attribute value is not allowed".to_string());
314 }
315 out.push(AttrRequirement::Value(name.to_string(), value.to_string()));
316 } else {
317 if !is_valid_attr_name(&token) {
318 return Err(format!("{token} is not a valid attribute name"));
319 }
320 out.push(AttrRequirement::Set(token));
321 }
322 }
323 if out.is_empty() {
324 return Err("empty attr magic is invalid".to_string());
325 }
326 Ok(out)
327}
328
329pub fn validate_attr_pathspecs(specs: &[String]) -> Result<(), String> {
334 for spec in specs {
335 if literal_global() || !spec.starts_with(":(") {
336 continue;
337 }
338 let Some(rest) = spec.strip_prefix(":(") else {
339 continue;
340 };
341 let Some(close) = rest.find(')') else {
342 continue;
343 };
344 let magic_part = &rest[..close];
345 let mut attr_count = 0usize;
346 for token in split_long_magic_tokens(magic_part) {
347 let Some(expr) = token.trim().strip_prefix("attr:") else {
348 continue;
349 };
350 attr_count += 1;
351 if attr_count > 1 {
352 return Err("Only one 'attr:' specification is allowed.".to_string());
353 }
354 parse_attr_requirements(expr)?;
355 }
356 }
357 Ok(())
358}
359
360fn split_long_magic_tokens(magic_part: &str) -> Vec<String> {
361 let mut tokens = Vec::new();
362 let mut cur = String::new();
363 let mut escaped = false;
364 for ch in magic_part.chars() {
365 if escaped {
366 cur.push('\\');
367 cur.push(ch);
368 escaped = false;
369 continue;
370 }
371 if ch == '\\' {
372 escaped = true;
373 continue;
374 }
375 if ch == ',' {
376 tokens.push(cur.trim().to_string());
377 cur.clear();
378 continue;
379 }
380 cur.push(ch);
381 }
382 if escaped {
383 cur.push('\\');
384 }
385 tokens.push(cur.trim().to_string());
386 tokens
387}
388
389fn parse_long_magic(rest_after_paren: &str) -> Option<(PathspecMagic, &str)> {
390 let close = rest_after_paren.find(')')?;
391 let magic_part = &rest_after_paren[..close];
392 let tail = &rest_after_paren[close + 1..];
393 let mut magic = PathspecMagic::default();
394 for raw in split_long_magic_tokens(magic_part) {
395 let token = raw.trim();
396 if token.is_empty() {
397 continue;
398 }
399 if let Some(p) = token.strip_prefix("prefix:") {
400 magic.prefix = Some(p.to_string());
401 continue;
402 }
403 if let Some(expr) = token.strip_prefix("attr:") {
404 if let Ok(reqs) = parse_attr_requirements(expr) {
405 magic.attr_requirements = reqs;
406 }
407 continue;
408 }
409 if token.eq_ignore_ascii_case("literal") {
410 magic.literal = true;
411 } else if token.eq_ignore_ascii_case("glob") {
412 magic.glob = true;
413 } else if token.eq_ignore_ascii_case("icase") {
414 magic.icase = true;
415 } else if token.eq_ignore_ascii_case("exclude") {
416 magic.exclude = true;
417 } else if token.eq_ignore_ascii_case("top") {
418 magic.top = true;
419 }
420 }
421 Some((magic, tail))
422}
423
424fn parse_short_magic(elem: &str) -> (PathspecMagic, &str) {
426 let bytes = elem.as_bytes();
427 let mut i = 1usize;
428 let mut magic = PathspecMagic::default();
429 while i < bytes.len() && bytes[i] != b':' {
430 let ch = bytes[i];
431 if ch == b'^' {
432 magic.exclude = true;
433 i += 1;
434 continue;
435 }
436 let is_magic = match ch {
437 b'!' => {
438 magic.exclude = true;
439 true
440 }
441 b'/' => {
442 magic.top = true;
443 true
444 } _ => false,
446 };
447 if is_magic {
448 i += 1;
449 continue;
450 }
451 break;
452 }
453 if i < bytes.len() && bytes[i] == b':' {
454 i += 1;
455 }
456 (magic, &elem[i..])
457}
458
459fn parse_element_magic(elem: &str) -> (PathspecMagic, &str) {
461 if !elem.starts_with(':') || literal_global() {
462 return (PathspecMagic::default(), elem);
463 }
464 if let Some(rest) = elem.strip_prefix(":(") {
465 return parse_long_magic(rest).unwrap_or((PathspecMagic::default(), elem));
466 }
467 parse_short_magic(elem)
468}
469
470fn combine_magic(element: PathspecMagic) -> PathspecMagic {
471 let mut m = element;
472 if literal_global() {
473 m.literal = true;
474 }
475 if glob_global() && !m.literal {
476 m.glob = true;
477 }
478 if icase_global() {
479 m.icase = true;
480 }
481 if noglob_global() && !m.glob {
482 m.literal = true;
483 }
484 m
485}
486
487fn strip_top_magic(mut pattern: &str) -> &str {
488 if let Some(r) = pattern.strip_prefix(":/") {
489 pattern = r;
490 }
491 pattern
492}
493
494#[must_use]
499pub fn bloom_lookup_prefix_with_cwd(
500 spec: &str,
501 cwd_from_repo_root: Option<&str>,
502) -> Option<String> {
503 let (elem_magic, raw_pattern) = parse_element_magic(spec);
504 let magic = combine_magic(elem_magic);
505 if magic.exclude || magic.icase {
506 return None;
507 }
508 let pattern = strip_top_magic(raw_pattern);
509 if pattern.is_empty() {
510 return None;
511 }
512 let combined = if magic.top {
513 let cwd = cwd_from_repo_root.unwrap_or("").trim_end_matches('/');
514 if cwd.is_empty() {
515 pattern.to_string()
516 } else {
517 format!("{cwd}/{pattern}")
518 }
519 } else {
520 pattern.to_string()
521 };
522 let pattern = combined.as_str();
523 let mut len = simple_length(pattern);
524 if len != pattern.len() {
525 while len > 0 && pattern.as_bytes()[len - 1] != b'/' {
526 len -= 1;
527 }
528 }
529 while len > 0 && pattern.as_bytes()[len - 1] == b'/' {
530 len -= 1;
531 }
532 if len == 0 {
533 return None;
534 }
535 Some(combined[..len].to_string())
536}
537
538#[must_use]
539pub fn bloom_lookup_prefix(spec: &str) -> Option<String> {
540 bloom_lookup_prefix_with_cwd(spec, None)
541}
542
543#[must_use]
545pub fn pathspecs_allow_bloom(specs: &[String]) -> bool {
546 specs.iter().all(|s| {
547 !s.is_empty() && !pathspec_is_exclude(s) && bloom_lookup_prefix_with_cwd(s, None).is_some()
548 })
549}
550
551#[must_use]
556pub fn path_allowed_by_pathspec_list(specs: &[String], path: &str) -> bool {
557 let mut has_positive = false;
558 let mut positive_match = false;
559 for s in specs {
560 let (elem, raw_pattern) = parse_element_magic(s);
561 let magic = combine_magic(elem);
562 if magic.exclude {
563 if path_matches_pathspec_tail(raw_pattern, path, magic) {
564 return false;
565 }
566 continue;
567 }
568 has_positive = true;
569 if pathspec_matches(s, path) {
570 positive_match = true;
571 }
572 }
573 !has_positive || positive_match
574}
575
576#[must_use]
578pub fn pathspec_contributes_match(spec: &str, path: &str) -> bool {
579 pathspec_matches(spec, path) || pathspec_exclude_matches(spec, path)
580}
581
582fn path_matches_pathspec_tail(raw_pattern: &str, path: &str, magic: PathspecMagic) -> bool {
583 if magic.literal && magic.glob {
584 return false;
585 }
586 let pattern = strip_top_magic(raw_pattern);
587 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
588 if !path.starts_with(prefix) {
589 return false;
590 }
591 &path[prefix.len()..]
592 } else {
593 path
594 };
595 pathspec_matches_tail(pattern, path_for_match, magic)
596}
597
598#[must_use]
603pub fn pathspec_matches(spec: &str, path: &str) -> bool {
604 matches_pathspec(spec, path)
605}
606
607#[must_use]
609pub fn pathspec_is_exclude(spec: &str) -> bool {
610 let (elem_magic, _) = parse_element_magic(spec);
611 combine_magic(elem_magic).exclude
612}
613
614#[must_use]
619pub fn pathspec_wants_descent_into_tree(spec: &str, full_name: &str) -> bool {
620 if pathspec_is_exclude(spec) {
621 return false;
622 }
623 let (elem_magic, raw_pattern) = parse_element_magic(spec);
624 let magic = combine_magic(elem_magic);
625 if magic.exclude {
626 return false;
627 }
628 let pattern = strip_top_magic(raw_pattern);
629 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
630 if pattern.is_empty() || pattern == "." {
631 return true;
632 }
633 let dir_prefix = format!("{full_name}/");
634 if pattern.starts_with(&dir_prefix) {
635 return true;
636 }
637 let probe = format!("{full_name}/.__grit_ls_tree_probe__");
638 matches_ls_tree_pathspec(spec, &probe, 0o100644, &[])
639}
640
641#[must_use]
644pub fn matches_pathspec_set_for_object_ls_tree(
645 specs: &[String],
646 path: &str,
647 mode: u32,
648 attr_rules: &[AttrRule],
649) -> bool {
650 if specs.is_empty() {
651 return true;
652 }
653 let mut positives: Vec<&str> = Vec::new();
654 let mut excludes: Vec<&str> = Vec::new();
655 for s in specs {
656 if pathspec_is_exclude(s) {
657 excludes.push(s.as_str());
658 } else {
659 positives.push(s.as_str());
660 }
661 }
662 let positive_ok = if positives.is_empty() {
663 true
664 } else {
665 positives
666 .iter()
667 .any(|s| matches_ls_tree_pathspec(s, path, mode, attr_rules))
668 };
669 if !positive_ok {
670 return false;
671 }
672 for ex in excludes {
673 if matches_ls_tree_pathspec(ex, path, mode, attr_rules) {
674 return false;
675 }
676 }
677 true
678}
679
680#[must_use]
683pub fn matches_pathspec_set_for_object(
684 specs: &[String],
685 path: &str,
686 mode: u32,
687 attr_rules: &[AttrRule],
688) -> bool {
689 if specs.is_empty() {
690 return true;
691 }
692 let mut positives: Vec<&str> = Vec::new();
693 let mut excludes: Vec<&str> = Vec::new();
694 for s in specs {
695 if pathspec_is_exclude(s) {
696 excludes.push(s.as_str());
697 } else {
698 positives.push(s.as_str());
699 }
700 }
701 let positive_ok = if positives.is_empty() {
702 true
703 } else {
704 positives
705 .iter()
706 .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
707 };
708 if !positive_ok {
709 return false;
710 }
711 for ex in excludes {
712 if matches_pathspec_for_object(ex, path, mode, attr_rules) {
713 return false;
714 }
715 }
716 true
717}
718
719#[must_use]
721pub fn pathspec_has_top(spec: &str) -> bool {
722 let (elem_magic, _) = parse_element_magic(spec);
723 combine_magic(elem_magic).top
724}
725
726fn pathspec_match_one_positive(path: &str, magic: PathspecMagic, raw_pattern: &str) -> bool {
727 if magic.literal && magic.glob {
728 return false;
729 }
730 let pattern = strip_top_magic(raw_pattern);
731 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
732 if !path.starts_with(prefix) {
733 return false;
734 }
735 &path[prefix.len()..]
736 } else {
737 path
738 };
739 pathspec_matches_tail(pattern, path_for_match, magic)
740}
741
742fn attr_requirements_match(
743 requirements: &[AttrRequirement],
744 attr_rules: &[AttrRule],
745 path: &str,
746 is_dir: bool,
747 mode: u32,
748) -> bool {
749 requirements.iter().all(|req| {
750 let value = if req.name() == "builtin_objectmode" {
751 if mode == 0 {
752 None
753 } else {
754 Some(format!("{mode:06o}"))
755 }
756 } else {
757 path_gitattribute_value(attr_rules, path, is_dir, req.name())
758 };
759 match req {
760 AttrRequirement::Set(_) => value.as_deref() == Some("set"),
761 AttrRequirement::Unset(_) => value.as_deref() == Some("unset"),
762 AttrRequirement::Unspecified(_) => value.is_none(),
763 AttrRequirement::Value(_, expected) => value.as_deref() == Some(expected.as_str()),
764 }
765 })
766}
767
768fn matches_pathspec_element_with_context(
769 spec: &str,
770 path: &str,
771 ctx: PathspecMatchContext,
772) -> bool {
773 let (elem_magic, raw_pattern) = parse_element_magic(spec);
774 let magic = combine_magic(elem_magic);
775 if magic.exclude {
776 return false;
777 }
778 if magic.literal && magic.glob {
779 return false;
780 }
781 if !magic.attr_requirements.is_empty() {
782 return false;
783 }
784 if magic.literal || magic.glob || magic.icase {
785 return pathspec_matches(spec, path);
786 }
787 let pattern = strip_top_magic(raw_pattern);
788 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
789 if !path.starts_with(prefix) {
790 return false;
791 }
792 &path[prefix.len()..]
793 } else {
794 path
795 };
796 matches_pathspec_with_context(pattern, path_for_match, ctx)
797}
798
799fn pathspec_exclude_element_matches_with_context(
800 spec: &str,
801 path: &str,
802 ctx: PathspecMatchContext,
803) -> bool {
804 let (elem_magic, raw_pattern) = parse_element_magic(spec);
805 let mut magic = combine_magic(elem_magic);
806 if !magic.exclude {
807 return false;
808 }
809 magic.exclude = false;
810 if magic.literal && magic.glob {
811 return false;
812 }
813 if !magic.attr_requirements.is_empty() {
814 return false;
817 }
818 if magic.literal || magic.glob || magic.icase {
819 return pathspec_match_one_positive(path, magic, raw_pattern);
820 }
821 let pattern = strip_top_magic(raw_pattern);
822 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
823 if !path.starts_with(prefix) {
824 return false;
825 }
826 &path[prefix.len()..]
827 } else {
828 path
829 };
830 matches_pathspec_with_context(pattern, path_for_match, ctx)
831}
832
833#[must_use]
836pub fn pathspec_exclude_matches(spec: &str, path: &str) -> bool {
837 pathspec_exclude_element_matches_with_context(spec, path, PathspecMatchContext::default())
838}
839
840#[must_use]
845pub fn extend_pathspec_list_implicit_cwd(
846 specs: &[String],
847 cwd_from_repo_root: Option<&str>,
848) -> Vec<String> {
849 if specs.is_empty() {
850 return specs.to_vec();
851 }
852 if !specs.iter().all(|s| pathspec_is_exclude(s)) {
853 return specs.to_vec();
854 }
855 let any_top = specs.iter().any(|s| pathspec_has_top(s));
856 if any_top {
857 return specs.to_vec();
858 }
859 let Some(cwd) = cwd_from_repo_root.map(str::trim).filter(|s| !s.is_empty()) else {
860 return specs.to_vec();
861 };
862 let cwd = cwd.trim_end_matches('/');
863 if cwd.is_empty() {
864 return specs.to_vec();
865 }
866 let mut out = Vec::with_capacity(specs.len() + 1);
867 out.push(format!("{cwd}/"));
868 out.extend_from_slice(specs);
869 out
870}
871
872#[must_use]
876pub fn matches_pathspec_list(path: &str, specs: &[String]) -> bool {
877 matches_pathspec_list_with_context(path, specs, PathspecMatchContext::default())
878}
879
880#[must_use]
882pub fn matches_pathspec_list_with_context(
883 path: &str,
884 specs: &[String],
885 ctx: PathspecMatchContext,
886) -> bool {
887 if specs.is_empty() {
888 return true;
889 }
890 let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
891 let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
892 let positive = if positive_specs.is_empty() {
893 true
894 } else {
895 positive_specs
896 .iter()
897 .any(|s| matches_pathspec_element_with_context(s, path, ctx))
898 };
899 if !positive {
900 return false;
901 }
902 if !has_exclude {
903 return true;
904 }
905 let excluded = specs.iter().any(|s| {
906 pathspec_is_exclude(s) && pathspec_exclude_element_matches_with_context(s, path, ctx)
907 });
908 !excluded
909}
910
911#[must_use]
913pub fn matches_pathspec_list_for_object(
914 path: &str,
915 mode: u32,
916 attr_rules: &[AttrRule],
917 specs: &[String],
918) -> bool {
919 if specs.is_empty() {
920 return true;
921 }
922 let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
923 let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
924 let positive = if positive_specs.is_empty() {
925 true
926 } else {
927 positive_specs
928 .iter()
929 .any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
930 };
931 if !positive {
932 return false;
933 }
934 if !has_exclude {
935 return true;
936 }
937 let excluded = specs.iter().any(|s| {
938 pathspec_is_exclude(s) && matches_pathspec_exclude_for_object(s, path, mode, attr_rules)
939 });
940 !excluded
941}
942
943fn matches_pathspec_exclude_for_object(
944 spec: &str,
945 path: &str,
946 mode: u32,
947 attr_rules: &[AttrRule],
948) -> bool {
949 let (elem_magic, raw_pattern) = parse_element_magic(spec);
950 let mut magic = combine_magic(elem_magic);
951 if !magic.exclude {
952 return false;
953 }
954 magic.exclude = false;
955 if magic.literal && magic.glob {
956 return false;
957 }
958 let ctx = context_from_mode_bits(mode);
959 let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
960 if !magic.attr_requirements.is_empty()
961 && !attr_requirements_match(
962 &magic.attr_requirements,
963 attr_rules,
964 path,
965 is_dir_for_attr,
966 mode,
967 )
968 {
969 return false;
970 }
971 let pattern = strip_top_magic(raw_pattern);
972 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
973 if !path.starts_with(prefix) {
974 return false;
975 }
976 &path[prefix.len()..]
977 } else {
978 path
979 };
980 if magic.literal || magic.glob || magic.icase {
981 pathspec_matches_tail(pattern, path_for_match, magic)
982 } else {
983 matches_pathspec_with_context(pattern, path_for_match, ctx)
984 }
985}
986
987fn pathspec_matches_tail(pattern: &str, path: &str, magic: PathspecMagic) -> bool {
988 if pattern.is_empty() {
989 return true;
990 }
991
992 let flags = if magic.icase { WM_CASEFOLD } else { 0 };
993
994 if magic.literal {
995 return literal_prefix_match(pattern, path);
996 }
997
998 let wm_flags = if magic.glob {
999 flags | WM_PATHNAME
1000 } else {
1001 flags
1002 };
1003
1004 let pattern_bytes = pattern.as_bytes();
1005 let path_bytes = path.as_bytes();
1006 let simple = simple_length(pattern);
1007
1008 if ps_str_eq(pattern, path, magic.icase) {
1012 return true;
1013 }
1014 if simple == pattern.len() {
1015 if let Some(prefix) = pattern.strip_suffix('/') {
1016 if ps_str_eq(prefix, path, magic.icase) {
1017 return true;
1018 }
1019 let prefix_slash = format!("{prefix}/");
1020 if path_starts_with(path, &prefix_slash, magic.icase) {
1021 return true;
1022 }
1023 } else {
1024 let prefix_slash = format!("{pattern}/");
1025 if path_starts_with(path, &prefix_slash, magic.icase) {
1026 return true;
1027 }
1028 }
1029 }
1030
1031 if magic.glob && !path.contains('/') && pattern.starts_with("**/") {
1033 if wildmatch(pattern_bytes, path_bytes, wm_flags) {
1034 return true;
1035 }
1036 if let Some(suffix) = pattern.strip_prefix("**/") {
1037 if wildmatch(suffix.as_bytes(), path_bytes, wm_flags) {
1038 return true;
1039 }
1040 }
1041 }
1042
1043 if simple < pattern.len() {
1045 if path_bytes.len() < simple {
1046 return false;
1047 }
1048 let path_lit = &path_bytes[..simple];
1049 let pat_lit = &pattern_bytes[..simple];
1050 let same = if magic.icase {
1051 path_lit.eq_ignore_ascii_case(pat_lit)
1052 } else {
1053 path_lit == pat_lit
1054 };
1055 if !same {
1056 return false;
1057 }
1058 let pat_rest = &pattern[simple..];
1059 let path_rest = &path[simple..];
1060 return wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), wm_flags);
1061 }
1062
1063 ps_str_eq(pattern, path, magic.icase)
1064 || path_starts_with(path, &format!("{pattern}/"), magic.icase)
1065}
1066
1067fn ps_str_eq(a: &str, b: &str, icase: bool) -> bool {
1068 if icase {
1069 a.eq_ignore_ascii_case(b)
1070 } else {
1071 a == b
1072 }
1073}
1074
1075fn path_starts_with(path: &str, prefix: &str, icase: bool) -> bool {
1076 if icase {
1077 path.get(..prefix.len())
1078 .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
1079 } else {
1080 path.starts_with(prefix)
1081 }
1082}
1083
1084fn literal_prefix_match(pattern: &str, path: &str) -> bool {
1085 if let Some(prefix) = pattern.strip_suffix('/') {
1086 return path == prefix || path.starts_with(&format!("{prefix}/"));
1087 }
1088 path == pattern || path.starts_with(&format!("{pattern}/"))
1089}
1090
1091fn ls_tree_literal_match(pattern: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1093 if let Some(prefix) = pattern.strip_suffix('/') {
1094 if path.starts_with(&format!("{prefix}/")) {
1095 return true;
1096 }
1097 if path == prefix {
1098 return ctx.is_directory || ctx.is_git_submodule;
1099 }
1100 return false;
1101 }
1102 path == pattern || path.starts_with(&format!("{pattern}/"))
1103}
1104
1105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1110pub struct PathspecMatchContext {
1111 pub is_directory: bool,
1113 pub is_git_submodule: bool,
1115}
1116
1117#[must_use]
1123pub fn matches_pathspec(spec: &str, path: &str) -> bool {
1124 matches_pathspec_with_context(spec, path, PathspecMatchContext::default())
1125}
1126
1127#[must_use]
1131pub fn matches_pathspec_with_context(spec: &str, path: &str, ctx: PathspecMatchContext) -> bool {
1132 let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1133 precompose_utf8_path(spec)
1134 } else {
1135 Cow::Borrowed(spec)
1136 };
1137 let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1138 precompose_utf8_path(path)
1139 } else {
1140 Cow::Borrowed(path)
1141 };
1142 let spec = spec_nfc.as_ref();
1143 let path = path_nfc.as_ref();
1144
1145 let trimmed = spec.strip_prefix("./").unwrap_or(spec);
1146 if trimmed == "." || trimmed.is_empty() {
1147 return true;
1148 }
1149
1150 let (elem_magic, raw_pattern) = parse_element_magic(trimmed);
1151 let magic = combine_magic(elem_magic);
1152
1153 if magic.literal && magic.glob {
1154 return false;
1155 }
1156 if magic.exclude {
1157 return false;
1158 }
1159
1160 let pattern = strip_top_magic(raw_pattern);
1161 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1162 if !path.starts_with(prefix) {
1163 return false;
1164 }
1165 &path[prefix.len()..]
1166 } else {
1167 path
1168 };
1169
1170 if magic.literal {
1171 if let Some(prefix) = pattern.strip_suffix('/') {
1172 if path_for_match.starts_with(&format!("{prefix}/")) {
1173 return true;
1174 }
1175 if path_for_match == prefix {
1176 return ctx.is_directory || ctx.is_git_submodule;
1177 }
1178 return false;
1179 }
1180 return path_for_match == pattern || path_for_match.starts_with(&format!("{pattern}/"));
1181 }
1182
1183 if let Some(prefix) = pattern.strip_suffix('/') {
1185 if simple_length(pattern) == pattern.len() {
1186 if path_for_match.starts_with(&format!("{prefix}/")) {
1187 return true;
1188 }
1189 if path_for_match == prefix {
1190 return ctx.is_directory || ctx.is_git_submodule;
1191 }
1192 return false;
1193 }
1194 }
1195
1196 if pathspec_matches_tail(pattern, path_for_match, magic) {
1197 return true;
1198 }
1199
1200 if (ctx.is_directory || ctx.is_git_submodule)
1201 && !path_for_match.is_empty()
1202 && pattern.len() > path_for_match.len()
1203 && pattern.as_bytes().get(path_for_match.len()) == Some(&b'/')
1204 && pattern.starts_with(path_for_match)
1205 && simple_length(pattern) < pattern.len()
1206 {
1207 return true;
1208 }
1209
1210 false
1211}
1212
1213#[must_use]
1215pub fn context_from_mode_octal(mode: &str) -> PathspecMatchContext {
1216 let Ok(bits) = u32::from_str_radix(mode, 8) else {
1217 return PathspecMatchContext::default();
1218 };
1219 context_from_mode_bits(bits)
1220}
1221
1222#[must_use]
1224pub fn context_from_mode_bits(mode: u32) -> PathspecMatchContext {
1225 let ty = mode & 0o170000;
1226 PathspecMatchContext {
1227 is_directory: ty == 0o040000,
1228 is_git_submodule: ty == 0o160000,
1229 }
1230}
1231
1232#[must_use]
1238pub fn matches_ls_tree_pathspec(
1239 spec: &str,
1240 path: &str,
1241 mode: u32,
1242 attr_rules: &[AttrRule],
1243) -> bool {
1244 let (elem_magic, raw_pattern) = parse_element_magic(spec);
1245 let mut magic = combine_magic(elem_magic);
1246 magic.exclude = false;
1247
1248 if magic.literal && magic.glob {
1249 return false;
1250 }
1251
1252 let ctx = context_from_mode_bits(mode);
1253 let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1254
1255 if !magic.attr_requirements.is_empty()
1256 && !attr_requirements_match(
1257 &magic.attr_requirements,
1258 attr_rules,
1259 path,
1260 is_dir_for_attr,
1261 mode,
1262 )
1263 {
1264 return false;
1265 }
1266
1267 let pattern = strip_top_magic(raw_pattern);
1268 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1269 if !path.starts_with(prefix) {
1270 return false;
1271 }
1272 &path[prefix.len()..]
1273 } else {
1274 path
1275 };
1276
1277 if magic.literal || magic.glob || magic.icase {
1278 return pathspec_matches_tail(pattern, path_for_match, magic);
1279 }
1280
1281 let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1282 precompose_utf8_path(pattern)
1283 } else {
1284 Cow::Borrowed(pattern)
1285 };
1286 let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
1287 precompose_utf8_path(path_for_match)
1288 } else {
1289 Cow::Borrowed(path_for_match)
1290 };
1291 let pattern = spec_nfc.as_ref();
1292 let path = path_nfc.as_ref();
1293
1294 let trimmed = pattern.strip_prefix("./").unwrap_or(pattern);
1295 if trimmed == "." || trimmed.is_empty() {
1296 return true;
1297 }
1298
1299 let uses_star_or_question = trimmed.contains('*') || trimmed.contains('?');
1300 if !uses_star_or_question {
1301 return ls_tree_literal_match(trimmed, path, ctx);
1302 }
1303
1304 let nwl = simple_length(trimmed);
1305 let flags = 0u32;
1306 if nwl == trimmed.len() {
1307 return wildmatch(trimmed.as_bytes(), path.as_bytes(), flags);
1308 }
1309 let lit = trimmed.as_bytes().get(..nwl).unwrap_or_default();
1310 let path_b = path.as_bytes();
1311 if path_b.len() < nwl {
1312 return false;
1313 }
1314 if &path_b[..nwl] != lit {
1315 return false;
1316 }
1317 let pat_rest = &trimmed[nwl..];
1318 let path_rest = &path[nwl..];
1319 wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), flags)
1320}
1321
1322#[must_use]
1327pub fn matches_pathspec_for_object(
1328 spec: &str,
1329 path: &str,
1330 mode: u32,
1331 attr_rules: &[AttrRule],
1332) -> bool {
1333 let (elem_magic, raw_pattern) = parse_element_magic(spec);
1334 let mut magic = combine_magic(elem_magic);
1335 magic.exclude = false;
1336
1337 if magic.literal && magic.glob {
1338 return false;
1339 }
1340
1341 let ctx = context_from_mode_bits(mode);
1342 let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
1343
1344 if !magic.attr_requirements.is_empty()
1345 && !attr_requirements_match(
1346 &magic.attr_requirements,
1347 attr_rules,
1348 path,
1349 is_dir_for_attr,
1350 mode,
1351 )
1352 {
1353 return false;
1354 }
1355
1356 let pattern = strip_top_magic(raw_pattern);
1357 let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
1358 if !path.starts_with(prefix) {
1359 return false;
1360 }
1361 &path[prefix.len()..]
1362 } else {
1363 path
1364 };
1365 if magic.literal || magic.glob || magic.icase {
1366 pathspec_matches_tail(pattern, path_for_match, magic)
1367 } else {
1368 matches_pathspec_with_context(pattern, path_for_match, ctx)
1369 }
1370}
1371
1372#[must_use]
1375pub fn wildmatch_flags_icase_glob(icase: bool, glob: bool) -> u32 {
1376 let mut f = if glob { WM_PATHNAME } else { 0 };
1377 if icase {
1378 f |= WM_CASEFOLD;
1379 }
1380 f
1381}
1382
1383#[cfg(test)]
1384mod tree_entry_pathspec_tests {
1385 use super::*;
1386
1387 #[test]
1388 fn t6130_bracket_filename_matches_pathspec() {
1389 assert!(matches_pathspec("f[o][o]", "f[o][o]"));
1390 assert!(matches_pathspec(":(glob)f[o][o]", "f[o][o]"));
1391 }
1392
1393 #[test]
1394 fn literal_prefix_and_exact() {
1395 assert!(matches_pathspec("path1", "path1/file1"));
1396 assert!(matches_pathspec_with_context(
1397 "path1/",
1398 "path1/file1",
1399 PathspecMatchContext::default()
1400 ));
1401 assert!(matches_pathspec("file0", "file0"));
1402 assert!(!matches_pathspec("path", "path1/file1"));
1403 }
1404
1405 #[test]
1406 fn ls_tree_bracket_in_name_is_literal_prefix() {
1407 assert!(matches_ls_tree_pathspec(
1408 "a[a]",
1409 "a[a]/three",
1410 0o100644,
1411 &[]
1412 ));
1413 assert!(!matches_pathspec_with_context(
1414 "a[a]",
1415 "a[a]/three",
1416 PathspecMatchContext::default()
1417 ));
1418 }
1419
1420 #[test]
1421 fn wildcards_cross_slash_by_default() {
1422 assert!(matches_pathspec("f*", "file0"));
1423 assert!(matches_pathspec("*file1", "path1/file1"));
1424 assert!(matches_pathspec_with_context(
1425 "path1/f*",
1426 "path1",
1427 PathspecMatchContext {
1428 is_directory: true,
1429 ..Default::default()
1430 }
1431 ));
1432 assert!(matches_pathspec("path1/*file1", "path1/file1"));
1433 }
1434
1435 #[test]
1436 fn glob_double_star_txt_at_repo_root() {
1437 assert!(pathspec_matches(":(glob)**/*.txt", "untracked.txt"));
1438 assert!(pathspec_matches(":(glob)**/*.txt", "d/untracked.txt"));
1439 }
1440
1441 #[test]
1442 fn trailing_slash_directory_only() {
1443 assert!(!matches_pathspec_with_context(
1444 "file0/",
1445 "file0",
1446 PathspecMatchContext::default()
1447 ));
1448 assert!(matches_pathspec_with_context(
1449 "file0/",
1450 "file0",
1451 PathspecMatchContext {
1452 is_directory: true,
1453 ..Default::default()
1454 }
1455 ));
1456 assert!(matches_pathspec_with_context(
1457 "submod/",
1458 "submod",
1459 PathspecMatchContext {
1460 is_git_submodule: true,
1461 ..Default::default()
1462 }
1463 ));
1464 }
1465
1466 #[test]
1467 fn exclude_top_short_magic_subtracts_from_positive() {
1468 let specs = vec!["*".to_string(), ":/!sub2".to_string()];
1469 assert!(matches_pathspec_list("sub/file", &specs));
1470 assert!(!matches_pathspec_list("sub2/file", &specs));
1471 assert!(pathspec_exclude_matches(":/!sub2", "sub2/file"));
1472 }
1473}
1474
1475#[cfg(test)]
1476mod pathspec_list_tests {
1477 use super::*;
1478 use crate::crlf::parse_gitattributes_content;
1479
1480 #[test]
1481 fn exclude_removes_paths_matching_icase_positive() {
1482 let specs = vec![
1483 ":(icase)*.txt".to_string(),
1484 ":(exclude)submodule/subsub/*".to_string(),
1485 ];
1486 assert!(path_allowed_by_pathspec_list(&specs, "submodule/g.txt"));
1487 assert!(!path_allowed_by_pathspec_list(
1488 &specs,
1489 "submodule/subsub/e.txt"
1490 ));
1491 }
1492
1493 #[test]
1494 fn prefixed_attr_exclude_removes_matching_child_path() {
1495 let specs = vec![
1496 "sub".to_string(),
1497 ":(exclude,attr:labelB,prefix:sub/)".to_string(),
1498 ];
1499 let exclude_only = vec![":(exclude,attr:labelB,prefix:sub/)".to_string()];
1500 let attrs = parse_gitattributes_content("fileB labelB\n");
1501 assert!(!matches_pathspec_list_for_object(
1502 "sub/fileB",
1503 0o100644,
1504 &attrs,
1505 &specs,
1506 ));
1507 assert!(!matches_pathspec_list_for_object(
1508 "sub/fileB",
1509 0o100644,
1510 &attrs,
1511 &exclude_only,
1512 ));
1513 }
1514}