1use std::collections::HashMap;
9use std::fmt;
10use std::fs;
11use std::path::{Component, Path, PathBuf};
12
13use crate::config::parse_path;
14use crate::error::{Error, Result};
15use crate::index::{Index, MODE_GITLINK};
16use crate::objects::ObjectKind;
17use crate::repo::Repository;
18use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum IgnoreSource {
23 PerDirectory(String),
25 InfoExclude,
27 CoreExcludesFile(String),
29}
30
31impl fmt::Display for IgnoreSource {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 match self {
34 Self::PerDirectory(path) => f.write_str(path),
35 Self::InfoExclude => f.write_str(".git/info/exclude"),
36 Self::CoreExcludesFile(path) => f.write_str(path),
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct IgnoreMatch {
44 pub source: IgnoreSource,
46 pub line_number: usize,
48 pub pattern_text: String,
50 pub negative: bool,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct IgnoreCheck {
57 pub path: String,
59 pub ignored: bool,
61 pub matched: Option<IgnoreMatch>,
63}
64
65#[derive(Debug, Clone, Default)]
87pub struct IgnorePatternList {
88 rules: Vec<IgnoreRule>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct IgnorePatternMatch {
94 pub line_number: usize,
96 pub pattern_text: String,
98 pub negative: bool,
100}
101
102impl IgnorePatternList {
103 #[must_use]
110 pub fn parse(content: &[u8], base_dir: &str) -> Self {
111 let content = String::from_utf8_lossy(content);
112 let base_dir = base_dir.trim_matches('/');
113 let source = IgnoreSource::PerDirectory(if base_dir.is_empty() {
114 ".gitignore".to_owned()
115 } else {
116 format!("{base_dir}/.gitignore")
117 });
118 let mut warnings = Vec::new();
119 let rules =
120 parse_gitignore_content(&content, source, base_dir, &mut warnings).unwrap_or_default();
121 Self { rules }
122 }
123
124 #[must_use]
126 pub fn is_empty(&self) -> bool {
127 self.rules.is_empty()
128 }
129
130 #[must_use]
132 pub fn len(&self) -> usize {
133 self.rules.len()
134 }
135
136 #[must_use]
142 pub fn last_match(
143 &self,
144 path: &str,
145 is_dir: bool,
146 ignore_case: bool,
147 ) -> Option<IgnorePatternMatch> {
148 self.rules
149 .iter()
150 .rev()
151 .find(|rule| rule_matches_path_exact(rule, path, is_dir, ignore_case))
152 .map(|rule| IgnorePatternMatch {
153 line_number: rule.line_number,
154 pattern_text: rule.pattern_text.clone(),
155 negative: rule.negative,
156 })
157 }
158
159 #[must_use]
162 pub fn is_ignored(&self, path: &str, is_dir: bool, ignore_case: bool) -> Option<bool> {
163 self.last_match(path, is_dir, ignore_case)
164 .map(|m| !m.negative)
165 }
166}
167
168fn rule_matches_path_exact(rule: &IgnoreRule, path: &str, is_dir: bool, ignore_case: bool) -> bool {
171 if rule.directory_only && !is_dir {
172 return false;
173 }
174 let Some(rel_to_base) = strip_base(&rule.base_dir, path, ignore_case) else {
175 return false;
176 };
177 if rel_to_base.is_empty() {
178 return false;
179 }
180 if rule.has_slash || rule.anchored {
181 rule_body_matches(rule, rel_to_base, true, ignore_case)
182 } else {
183 let basename = rel_to_base.rsplit('/').next().unwrap_or(rel_to_base);
184 rule_body_matches(rule, basename, false, ignore_case)
185 }
186}
187
188#[derive(Debug, Clone)]
189enum IgnorePatternKind {
190 Wild,
191 Literal(String),
192 Prefix(String),
193 Suffix(String),
194}
195
196#[derive(Debug, Clone)]
197struct IgnoreRule {
198 source: IgnoreSource,
199 line_number: usize,
200 pattern_text: String,
201 negative: bool,
202 directory_only: bool,
203 anchored: bool,
204 has_slash: bool,
205 body: String,
206 base_dir: String,
207 match_kind: IgnorePatternKind,
208}
209
210#[derive(Debug, Default, Clone)]
212pub struct IgnoreMatcher {
213 cli_rules: Vec<IgnoreRule>,
215 global_rules: Vec<IgnoreRule>,
216 info_rules: Vec<IgnoreRule>,
217 exclude_from_rules: Vec<IgnoreRule>,
219 gitignore_cache: HashMap<String, Vec<IgnoreRule>>,
220 per_directory_name: Option<String>,
222 read_per_directory: bool,
227 pub warnings: Vec<String>,
229 per_dir_dirs_cache: Option<(String, Vec<String>)>,
231 dir_exclude_stack_cache: HashMap<String, Vec<String>>,
233 ignore_case: bool,
235}
236
237impl IgnoreMatcher {
238 pub fn from_repository(repo: &Repository) -> Result<Self> {
252 let ignore_case = crate::crlf::config_ignorecase(repo.config()?.as_ref());
253 Ok(Self {
254 global_rules: load_global_excludes(repo)?,
255 info_rules: load_info_excludes(repo)?,
256 read_per_directory: true,
258 ignore_case,
259 ..Self::default()
260 })
261 }
262
263 #[must_use]
277 pub fn with_ignore_case(mut self, ignore_case: bool) -> Self {
278 self.ignore_case = ignore_case;
279 self
280 }
281
282 #[must_use]
284 pub fn ignore_case(&self) -> bool {
285 self.ignore_case
286 }
287
288 pub fn add_exclude_from_files(&mut self, paths: &[PathBuf], cwd: &Path) -> Result<()> {
292 for path in paths {
293 let resolved = if path.is_absolute() {
294 path.clone()
295 } else {
296 cwd.join(path)
297 };
298 let display = path.display().to_string();
299 let mut more = load_rules_from_file(
300 &resolved,
301 IgnoreSource::CoreExcludesFile(display),
302 String::new(),
303 false,
304 &mut self.warnings,
305 )?;
306 self.exclude_from_rules.append(&mut more);
307 }
308 Ok(())
309 }
310
311 pub fn set_per_directory_name(&mut self, name: &str) {
315 self.per_directory_name = Some(name.to_owned());
316 self.read_per_directory = true;
317 self.gitignore_cache.clear();
319 self.per_dir_dirs_cache = None;
320 self.dir_exclude_stack_cache.clear();
321 }
322
323 pub fn add_cli_excludes(&mut self, patterns: &[String]) {
325 for pat in patterns {
326 if let Some(rule) = parse_rule_line(
327 pat,
328 1,
329 IgnoreSource::PerDirectory("--exclude option".into()),
330 "",
331 ) {
332 self.cli_rules.push(rule);
333 }
334 }
335 }
336
337 #[must_use]
339 pub fn take_warnings(&mut self) -> Vec<String> {
340 std::mem::take(&mut self.warnings)
341 }
342
343 pub fn is_path_ignored(
348 &mut self,
349 repo: &Repository,
350 index: Option<&Index>,
351 repo_rel_path: &str,
352 is_dir: bool,
353 ) -> Result<bool> {
354 if is_tracked(index, repo_rel_path) {
355 return Ok(false);
356 }
357 let per_dir_dirs = self.ensure_per_dir_rules_loaded(repo, index, repo_rel_path)?;
358 let per_dir_groups = self.cached_rule_slices_for_dirs(&per_dir_dirs);
359 if !self.has_any_exclude_rules(&per_dir_groups) {
360 return Ok(false);
361 }
362 if self
363 .first_excluded_ancestor(repo_rel_path, &per_dir_groups)
364 .is_some()
365 {
366 return Ok(true);
367 }
368 Ok(self
369 .evaluate_against_groups(repo_rel_path, is_dir, &per_dir_groups)
370 .0)
371 }
372
373 pub fn check_path(
393 &mut self,
394 repo: &Repository,
395 index: Option<&Index>,
396 repo_rel_path: &str,
397 is_dir: bool,
398 ) -> Result<(bool, Option<IgnoreMatch>)> {
399 if is_tracked(index, repo_rel_path) {
400 return Ok((false, None));
401 }
402
403 let per_dir_dirs = self.ensure_per_dir_rules_loaded(repo, index, repo_rel_path)?;
412 let per_dir_groups = self.cached_rule_slices_for_dirs(&per_dir_dirs);
413
414 if let Some(ancestor_match) = self.first_excluded_ancestor(repo_rel_path, &per_dir_groups) {
423 return Ok((true, Some(ancestor_match)));
424 }
425
426 let (ignored, matched) =
427 self.evaluate_against_groups(repo_rel_path, is_dir, &per_dir_groups);
428
429 let all_rules: Vec<&IgnoreRule> = self.flatten_groups(&per_dir_groups);
431 let matched = refine_match_for_check_ignore_verbose(
432 repo_rel_path,
433 is_dir,
434 ignored,
435 matched,
436 &all_rules,
437 self.ignore_case,
438 );
439
440 Ok((ignored, matched))
441 }
442
443 fn evaluate_against_groups(
452 &self,
453 path: &str,
454 is_dir: bool,
455 per_dir_groups: &[&[IgnoreRule]],
456 ) -> (bool, Option<IgnoreMatch>) {
457 let groups = self.build_groups(per_dir_groups);
458 for group in &groups {
459 let mut group_match: Option<&&IgnoreRule> = None;
460 for rule in group {
461 if rule_matches(rule, path, is_dir, self.ignore_case) {
462 group_match = Some(rule);
463 }
464 }
465 if let Some(rule) = group_match {
466 return (
467 !rule.negative,
468 Some(IgnoreMatch {
469 source: rule.source.clone(),
470 line_number: rule.line_number,
471 pattern_text: rule.pattern_text.clone(),
472 negative: rule.negative,
473 }),
474 );
475 }
476 }
477 (false, None)
478 }
479
480 fn flatten_groups<'a>(&'a self, per_dir_groups: &[&'a [IgnoreRule]]) -> Vec<&'a IgnoreRule> {
483 self.build_groups(per_dir_groups)
484 .into_iter()
485 .flatten()
486 .collect()
487 }
488
489 fn build_groups<'a>(&'a self, per_dir_groups: &[&'a [IgnoreRule]]) -> Vec<Vec<&'a IgnoreRule>> {
492 let cmdl_rules: Vec<&IgnoreRule> = self.cli_rules.iter().collect();
493 let mut dir_groups: Vec<Vec<&IgnoreRule>> = Vec::new();
494 for rules in per_dir_groups.iter().rev() {
495 dir_groups.push(rules.iter().collect());
496 }
497 let file_rules: Vec<&IgnoreRule> = self
498 .exclude_from_rules
499 .iter()
500 .chain(self.global_rules.iter())
501 .chain(self.info_rules.iter())
502 .collect();
503
504 let mut groups: Vec<Vec<&IgnoreRule>> = Vec::new();
505 groups.push(cmdl_rules);
506 groups.extend(dir_groups);
507 groups.push(file_rules);
508 groups
509 }
510
511 fn first_excluded_ancestor(
520 &self,
521 repo_rel_path: &str,
522 per_dir_groups: &[&[IgnoreRule]],
523 ) -> Option<IgnoreMatch> {
524 let parent = parent_dir(repo_rel_path);
525 if parent.is_empty() {
526 return None;
527 }
528 let mut cur = String::new();
529 for (depth, segment) in parent.split('/').enumerate() {
530 if !cur.is_empty() {
531 cur.push('/');
532 }
533 cur.push_str(segment);
534 let upper = (depth + 1).min(per_dir_groups.len());
538 let slice = &per_dir_groups[..upper];
539 let (ignored, matched) = self.evaluate_against_groups(&cur, true, slice);
540 if ignored {
541 return matched;
542 }
543 }
544 None
545 }
546
547 fn has_any_exclude_rules(&self, per_dir_groups: &[&[IgnoreRule]]) -> bool {
552 !self.cli_rules.is_empty()
553 || !self.exclude_from_rules.is_empty()
554 || !self.global_rules.is_empty()
555 || !self.info_rules.is_empty()
556 || per_dir_groups.iter().any(|g| !g.is_empty())
557 }
558
559 pub fn note_directory_entered(
563 &mut self,
564 repo: &Repository,
565 index: Option<&Index>,
566 dir_rel: &str,
567 ) -> Result<()> {
568 if !self.read_per_directory {
569 return Ok(());
570 }
571 if self.gitignore_cache.contains_key(dir_rel) {
572 return Ok(());
573 }
574 let per_dir_name = self.per_directory_name.clone();
575 let rules = load_gitignore_for_dir(
576 repo,
577 index,
578 dir_rel,
579 per_dir_name.as_deref(),
580 &mut self.warnings,
581 )?;
582 self.gitignore_cache.insert(dir_rel.to_owned(), rules);
583 Ok(())
584 }
585
586 fn ensure_per_dir_rules_loaded(
587 &mut self,
588 repo: &Repository,
589 index: Option<&Index>,
590 repo_rel_path: &str,
591 ) -> Result<Vec<String>> {
592 if !self.read_per_directory {
593 return Ok(Vec::new());
594 }
595 let parent = parent_dir(repo_rel_path);
596 let dirs = match self.per_dir_dirs_cache.as_ref() {
597 Some((cached, cached_dirs)) if cached == parent => cached_dirs.clone(),
598 _ => {
599 if let Some(cached_dirs) = self.dir_exclude_stack_cache.get(parent) {
600 self.per_dir_dirs_cache = Some((parent.to_string(), cached_dirs.clone()));
601 cached_dirs.clone()
602 } else {
603 let mut dirs = Vec::new();
604 dirs.push(String::new());
605 if !parent.is_empty() {
606 let mut cur = String::new();
607 for segment in parent.split('/') {
608 if !cur.is_empty() {
609 cur.push('/');
610 }
611 cur.push_str(segment);
612 if worktree_path_is_existing_file(repo, &cur) {
613 break;
616 }
617 dirs.push(cur.clone());
618 }
619 }
620 self.per_dir_dirs_cache = Some((parent.to_string(), dirs.clone()));
621 self.dir_exclude_stack_cache
622 .insert(parent.to_string(), dirs.clone());
623 dirs
624 }
625 }
626 };
627
628 let per_dir_name = self.per_directory_name.clone();
629 for dir in &dirs {
630 if !self.gitignore_cache.contains_key(dir) {
631 let rules = load_gitignore_for_dir(
632 repo,
633 index,
634 dir,
635 per_dir_name.as_deref(),
636 &mut self.warnings,
637 )?;
638 self.gitignore_cache.insert(dir.clone(), rules);
639 }
640 }
641 Ok(dirs)
642 }
643
644 fn cached_rule_slices_for_dirs(&self, dirs: &[String]) -> Vec<&[IgnoreRule]> {
645 dirs.iter()
646 .map(|dir| {
647 self.gitignore_cache
648 .get(dir)
649 .map_or(&[] as &[IgnoreRule], Vec::as_slice)
650 })
651 .collect()
652 }
653}
654
655fn load_global_excludes(repo: &Repository) -> Result<Vec<IgnoreRule>> {
656 let config = repo.config()?;
657 let env = repo.environment();
658 let (resolved, display_path) = if let Some(raw_path) = config.get("core.excludesfile") {
659 let expanded = parse_path(env, &raw_path);
660 let resolved = if Path::new(&expanded).is_absolute() {
661 PathBuf::from(&expanded)
662 } else if let Some(work_tree) = &repo.work_tree() {
663 work_tree.join(&expanded)
664 } else {
665 repo.git_dir().join(&expanded)
666 };
667 (resolved, raw_path)
668 } else if let Some(path) = env.default_xdg_config_git_file("ignore") {
669 let display = path.display().to_string();
670 (path, display)
671 } else {
672 return Ok(Vec::new());
673 };
674
675 let mut sink = Vec::new();
676 load_rules_from_file(
677 &resolved,
678 IgnoreSource::CoreExcludesFile(display_path),
679 String::new(),
680 false,
681 &mut sink,
682 )
683}
684
685fn load_info_excludes(repo: &Repository) -> Result<Vec<IgnoreRule>> {
686 let path = repo.git_dir().join("info/exclude");
687 let mut sink = Vec::new();
688 load_rules_from_file(
689 &path,
690 IgnoreSource::InfoExclude,
691 String::new(),
692 false,
693 &mut sink,
694 )
695}
696
697fn load_gitignore_for_dir(
698 repo: &Repository,
699 index: Option<&Index>,
700 dir: &str,
701 per_dir_name: Option<&str>,
702 warnings: &mut Vec<String>,
703) -> Result<Vec<IgnoreRule>> {
704 let Some(work_tree) = &repo.work_tree() else {
705 return Ok(Vec::new());
706 };
707 let file_name = per_dir_name.unwrap_or(".gitignore");
708 let path = if dir.is_empty() {
709 work_tree.join(file_name)
710 } else {
711 work_tree.join(dir).join(file_name)
712 };
713 let source_display = if dir.is_empty() {
714 file_name.to_owned()
715 } else {
716 format!("{dir}/{file_name}")
717 };
718 let source = IgnoreSource::PerDirectory(source_display.clone());
719 let rel_key = if dir.is_empty() {
720 file_name.to_owned()
721 } else {
722 format!("{dir}/{file_name}")
723 };
724
725 match read_in_tree_pattern_file(&path)? {
728 InTreePatternFile::Symlink => {
729 warnings.push(crate::diagnostics::warning_line(&format!(
730 "unable to access '{source_display}': Too many levels of symbolic links"
731 )));
732 return Ok(Vec::new());
733 }
734 InTreePatternFile::Content(content) => {
735 return parse_gitignore_content(&content, source, dir, warnings);
736 }
737 InTreePatternFile::Missing => {}
738 }
739
740 if let Some(ix) = index {
741 if let Some(entry) = ix.get(rel_key.as_bytes(), 0) {
742 if let Ok(obj) = repo.odb().read(&entry.oid) {
743 if obj.kind == ObjectKind::Blob {
744 if let Ok(text) = std::str::from_utf8(&obj.data) {
745 return parse_gitignore_content(text, source, dir, warnings);
746 }
747 }
748 }
749 }
750 }
751
752 Ok(Vec::new())
753}
754
755fn parse_gitignore_content(
756 content: &str,
757 source: IgnoreSource,
758 base_dir: &str,
759 _warnings: &mut Vec<String>,
760) -> Result<Vec<IgnoreRule>> {
761 let mut rules = Vec::new();
762 for (idx, line) in content.split('\n').enumerate() {
765 let line = if idx == 0 {
766 line.strip_prefix('\u{feff}').unwrap_or(line)
767 } else {
768 line
769 };
770 if let Some(rule) = parse_rule_line(line, idx + 1, source.clone(), base_dir) {
771 rules.push(rule);
772 }
773 }
774 Ok(rules)
775}
776
777fn load_rules_from_file(
778 path: &Path,
779 source: IgnoreSource,
780 base_dir: String,
781 deny_symlink_gitignore: bool,
782 warnings: &mut Vec<String>,
783) -> Result<Vec<IgnoreRule>> {
784 if deny_symlink_gitignore && path.exists() {
785 if let Ok(meta) = fs::symlink_metadata(path) {
786 if meta.file_type().is_symlink() {
787 warnings.push(crate::diagnostics::warning_line(&format!(
788 "unable to access '{}': Too many levels of symbolic links",
789 source
790 )));
791 return Ok(Vec::new());
792 }
793 }
794 }
795 if path.exists() {
796 crate::worktree_rules::record_ignore_pattern_file_read(path);
797 }
798 let Some(content) = read_optional_text(path)? else {
799 return Ok(Vec::new());
800 };
801 parse_gitignore_content(&content, source, &base_dir, warnings)
802}
803
804fn trim_trailing_spaces_git(buf: &mut String) {
809 let mut bytes = std::mem::take(buf).into_bytes();
810 let mut last_space_start: Option<usize> = None;
811 let mut i = 0usize;
812 while i < bytes.len() {
813 match bytes[i] {
814 b' ' => {
815 if last_space_start.is_none() {
816 last_space_start = Some(i);
817 }
818 i += 1;
819 }
820 b'\\' => {
821 last_space_start = None;
822 i += 1;
823 if i < bytes.len() {
824 i += 1;
825 } else {
826 *buf = String::from_utf8_lossy(&bytes).into_owned();
827 return;
828 }
829 }
830 _ => {
831 last_space_start = None;
832 i += 1;
833 }
834 }
835 }
836 if let Some(start) = last_space_start {
837 bytes.truncate(start);
838 }
839 *buf = String::from_utf8_lossy(&bytes).into_owned();
840}
841
842fn parse_rule_line(
843 line: &str,
844 line_number: usize,
845 source: IgnoreSource,
846 base_dir: &str,
847) -> Option<IgnoreRule> {
848 let mut raw_line = line.strip_suffix('\r').unwrap_or(line).to_owned();
849 trim_trailing_spaces_git(&mut raw_line);
850 if raw_line.is_empty() || raw_line.starts_with('#') {
851 return None;
852 }
853
854 let pattern_text = raw_line.clone();
855 let mut raw = raw_line;
856
857 let mut negative = false;
858 if let Some(rest) = raw.strip_prefix('!') {
859 negative = true;
860 raw = rest.to_owned();
861 }
862 if raw.is_empty() {
863 return None;
864 }
865
866 let mut anchored = false;
867 if let Some(rest) = raw.strip_prefix('/') {
868 anchored = true;
869 raw = rest.to_owned();
870 }
871 if raw.is_empty() {
872 return None;
873 }
874
875 let mut directory_only = false;
876 if let Some(rest) = raw.strip_suffix('/') {
877 directory_only = true;
878 raw = rest.to_owned();
879 }
880 if raw.is_empty() {
881 return None;
882 }
883
884 let has_slash = raw.contains('/');
885 let body = raw;
886 let match_kind = classify_ignore_pattern(&body);
887 Some(IgnoreRule {
888 source,
889 line_number,
890 pattern_text,
891 negative,
892 directory_only,
893 anchored,
894 has_slash,
895 body,
896 base_dir: base_dir.to_owned(),
897 match_kind,
898 })
899}
900
901fn classify_ignore_pattern(body: &str) -> IgnorePatternKind {
902 if body.contains('\\') || body.contains(['?', '[']) {
905 return IgnorePatternKind::Wild;
906 }
907 if !body.contains('*') {
908 return IgnorePatternKind::Literal(body.to_owned());
909 }
910 if let Some(rest) = body.strip_prefix('*') {
911 if !rest.contains('*') && !rest.contains('/') {
912 return IgnorePatternKind::Suffix(rest.to_owned());
913 }
914 }
915 if let Some(rest) = body.strip_suffix('*') {
916 if !rest.contains('*') && !rest.contains('/') {
917 return IgnorePatternKind::Prefix(rest.to_owned());
918 }
919 }
920 IgnorePatternKind::Wild
921}
922
923fn base_dir_depth(base: &str) -> usize {
924 if base.is_empty() {
925 return 0;
926 }
927 base.split('/').count()
928}
929
930fn refine_match_for_check_ignore_verbose(
933 repo_rel_path: &str,
934 is_dir: bool,
935 ignored: bool,
936 matched: Option<IgnoreMatch>,
937 rules: &[&IgnoreRule],
938 ignore_case: bool,
939) -> Option<IgnoreMatch> {
940 let m = matched?;
941 if m.negative {
942 return Some(m);
943 }
944 if !ignored {
945 return Some(m);
946 }
947 let mut best: Option<&IgnoreRule> = None;
948 for rule in rules {
949 if !rule.directory_only {
950 continue;
951 }
952 if !rule_matches(rule, repo_rel_path, is_dir, ignore_case) {
953 continue;
954 }
955 match best {
956 None => best = Some(rule),
957 Some(b) if base_dir_depth(&rule.base_dir) < base_dir_depth(&b.base_dir) => {
958 best = Some(rule);
959 }
960 Some(b)
961 if base_dir_depth(&rule.base_dir) == base_dir_depth(&b.base_dir)
962 && rule.line_number < b.line_number =>
963 {
964 best = Some(rule);
965 }
966 _ => {}
967 }
968 }
969 Some(
970 best.map(|r| IgnoreMatch {
971 source: r.source.clone(),
972 line_number: r.line_number,
973 pattern_text: r.pattern_text.clone(),
974 negative: r.negative,
975 })
976 .unwrap_or(m),
977 )
978}
979
980fn rule_matches(rule: &IgnoreRule, repo_rel_path: &str, is_dir: bool, ignore_case: bool) -> bool {
981 if ignore_case {
982 rule_matches_with::<true>(rule, repo_rel_path, is_dir)
983 } else {
984 rule_matches_with::<false>(rule, repo_rel_path, is_dir)
985 }
986}
987
988fn rule_matches_with<const FOLD: bool>(
991 rule: &IgnoreRule,
992 repo_rel_path: &str,
993 is_dir: bool,
994) -> bool {
995 if rule.directory_only && !is_dir {
1000 return false;
1001 }
1002
1003 let Some(rel_to_base) = strip_base(&rule.base_dir, repo_rel_path, FOLD) else {
1004 return false;
1005 };
1006
1007 if rule.directory_only {
1008 if rule.has_slash || rule.anchored {
1009 for ancestor in ancestor_dirs(rel_to_base, is_dir) {
1010 if rule_body_matches(rule, &ancestor, true, FOLD) {
1011 return true;
1012 }
1013 }
1014 return false;
1015 }
1016 for ancestor in ancestor_dir_basenames(rel_to_base, is_dir) {
1017 if rule_body_matches(rule, ancestor, false, FOLD) {
1018 return true;
1019 }
1020 }
1021 return false;
1022 }
1023
1024 if rule.has_slash || rule.anchored {
1025 return rule_body_matches(rule, rel_to_base, true, FOLD);
1026 }
1027
1028 !rel_to_base.is_empty()
1029 && rel_to_base
1030 .split('/')
1031 .any(|name| rule_body_matches(rule, name, false, FOLD))
1032}
1033
1034fn is_tracked(index: Option<&Index>, repo_rel_path: &str) -> bool {
1035 let Some(index) = index else {
1036 return false;
1037 };
1038 if repo_rel_path.ends_with('/') {
1041 return false;
1042 }
1043 index.get(repo_rel_path.as_bytes(), 0).is_some()
1044}
1045
1046fn worktree_path_is_existing_file(repo: &Repository, repo_rel_path: &str) -> bool {
1048 let Some(work_tree) = repo.work_tree() else {
1049 return false;
1050 };
1051 let path = work_tree.join(repo_rel_path);
1052 path.metadata().map(|m| m.is_file()).unwrap_or(false)
1053}
1054
1055fn path_query_is_dir(repo: &Repository, repo_rel_path: &str) -> bool {
1056 if repo_rel_path.ends_with('/') {
1057 return true;
1058 }
1059 let Some(work_tree) = repo.work_tree() else {
1060 return false;
1061 };
1062 let path = work_tree.join(repo_rel_path);
1063 path.metadata().map(|m| m.is_dir()).unwrap_or(false)
1064}
1065
1066impl Repository {
1067 pub fn ignore_check(&self, paths: &[String], index: &Index) -> Result<Vec<IgnoreCheck>> {
1082 let mut matcher = IgnoreMatcher::from_repository(self)?;
1083 let mut out = Vec::with_capacity(paths.len());
1084 for path in paths {
1085 let is_dir = path_query_is_dir(self, path);
1086 let (ignored, matched) = matcher.check_path(self, Some(index), path, is_dir)?;
1087 out.push(IgnoreCheck {
1088 path: path.clone(),
1089 ignored,
1090 matched,
1091 });
1092 }
1093 Ok(out)
1094 }
1095}
1096
1097enum InTreePatternFile {
1099 Missing,
1100 Symlink,
1101 Content(String),
1102}
1103
1104fn read_in_tree_pattern_file(path: &Path) -> Result<InTreePatternFile> {
1107 let is_symlink = || fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink());
1108 #[cfg(unix)]
1109 let opened = {
1110 use std::os::unix::fs::OpenOptionsExt;
1111 fs::OpenOptions::new()
1112 .read(true)
1113 .custom_flags(libc::O_NOFOLLOW)
1114 .open(path)
1115 };
1116 #[cfg(not(unix))]
1117 let opened = if is_symlink() {
1118 return Ok(InTreePatternFile::Symlink);
1119 } else {
1120 fs::File::open(path)
1121 };
1122 let mut file = match opened {
1123 Ok(file) => file,
1124 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1125 return Ok(InTreePatternFile::Missing);
1126 }
1127 Err(_) if is_symlink() => return Ok(InTreePatternFile::Symlink),
1129 Err(err) => return Err(Error::Io(err)),
1130 };
1131 let mut content = String::new();
1132 std::io::Read::read_to_string(&mut file, &mut content).map_err(Error::Io)?;
1133 Ok(InTreePatternFile::Content(content))
1134}
1135
1136fn read_optional_text(path: &Path) -> Result<Option<String>> {
1137 match fs::read_to_string(path) {
1138 Ok(content) => Ok(Some(content)),
1139 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1140 Err(err) => Err(Error::Io(err)),
1141 }
1142}
1143
1144fn strip_base<'a>(base: &str, path: &'a str, ignore_case: bool) -> Option<&'a str> {
1147 if base.is_empty() {
1148 return Some(path);
1149 }
1150 if !starts_with_fold(path, base, ignore_case) {
1151 return None;
1152 }
1153 match path.as_bytes().get(base.len()) {
1154 None => Some(""),
1155 Some(b'/') => path.get(base.len() + 1..),
1157 Some(_) => None,
1158 }
1159}
1160
1161fn eq_fold(text: &str, pattern: &str, ignore_case: bool) -> bool {
1162 if ignore_case {
1163 text.eq_ignore_ascii_case(pattern)
1164 } else {
1165 text == pattern
1166 }
1167}
1168
1169fn starts_with_fold(text: &str, prefix: &str, ignore_case: bool) -> bool {
1170 if !ignore_case {
1171 return text.starts_with(prefix);
1172 }
1173 text.as_bytes()
1174 .get(..prefix.len())
1175 .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_bytes()))
1176}
1177
1178fn ends_with_fold(text: &str, suffix: &str, ignore_case: bool) -> bool {
1179 if !ignore_case {
1180 return text.ends_with(suffix);
1181 }
1182 text.len()
1183 .checked_sub(suffix.len())
1184 .and_then(|start| text.as_bytes().get(start..))
1185 .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix.as_bytes()))
1186}
1187
1188fn parent_dir(path: &str) -> &str {
1189 match path.rsplit_once('/') {
1190 Some((parent, _)) => parent,
1191 None => "",
1192 }
1193}
1194
1195fn ancestor_dirs(path: &str, is_dir: bool) -> Vec<String> {
1196 let mut out = Vec::new();
1197 if path.is_empty() {
1198 return out;
1199 }
1200 let parts: Vec<&str> = path.split('/').collect();
1201 let max = if is_dir {
1202 parts.len()
1203 } else {
1204 parts.len().saturating_sub(1)
1205 };
1206 for idx in 1..=max {
1207 out.push(parts[..idx].join("/"));
1208 }
1209 out
1210}
1211
1212fn ancestor_dir_basenames(path: &str, is_dir: bool) -> Vec<&str> {
1213 let mut out = Vec::new();
1214 let parts: Vec<&str> = if path.is_empty() {
1215 Vec::new()
1216 } else {
1217 path.split('/').collect()
1218 };
1219 let max = if is_dir {
1220 parts.len()
1221 } else {
1222 parts.len().saturating_sub(1)
1223 };
1224 for item in parts.iter().take(max) {
1225 out.push(*item);
1226 }
1227 out
1228}
1229
1230fn pattern_matches_kind(
1231 kind: &IgnorePatternKind,
1232 pattern: &str,
1233 text: &str,
1234 pathname: bool,
1235 ignore_case: bool,
1236) -> bool {
1237 match kind {
1238 IgnorePatternKind::Literal(lit) => eq_fold(text, lit, ignore_case),
1239 IgnorePatternKind::Prefix(prefix) => starts_with_fold(text, prefix, ignore_case),
1240 IgnorePatternKind::Suffix(suffix) => ends_with_fold(text, suffix, ignore_case),
1241 IgnorePatternKind::Wild => {
1242 let mut flags = if pathname { WM_PATHNAME } else { 0 };
1243 if ignore_case {
1244 flags |= WM_CASEFOLD;
1245 }
1246 wildmatch(pattern.as_bytes(), text.as_bytes(), flags)
1247 }
1248 }
1249}
1250
1251fn rule_body_matches(rule: &IgnoreRule, text: &str, pathname: bool, ignore_case: bool) -> bool {
1252 pattern_matches_kind(&rule.match_kind, &rule.body, text, pathname, ignore_case)
1253}
1254
1255#[derive(Debug, Clone)]
1257struct SparsePattern {
1258 negative: bool,
1259 directory_only: bool,
1260 anchored: bool,
1261 has_slash: bool,
1262 body: String,
1263}
1264
1265impl SparsePattern {
1266 fn from_line(line: &str) -> Option<Self> {
1267 let mut raw_line = line.strip_suffix('\r').unwrap_or(line).to_owned();
1268 trim_trailing_spaces_git(&mut raw_line);
1269 if raw_line.is_empty() || raw_line.starts_with('#') {
1270 return None;
1271 }
1272
1273 let mut raw = raw_line;
1274
1275 let mut negative = false;
1276 if let Some(rest) = raw.strip_prefix('!') {
1277 negative = true;
1278 raw = rest.to_owned();
1279 }
1280 if raw.is_empty() {
1281 return None;
1282 }
1283
1284 let mut directory_only = false;
1287 if raw.len() > 1 && raw.ends_with('/') {
1288 directory_only = true;
1289 raw.pop();
1290 }
1291 if raw.is_empty() {
1292 return None;
1293 }
1294
1295 let mut anchored = false;
1296 if let Some(rest) = raw.strip_prefix('/') {
1297 anchored = true;
1298 raw = rest.to_owned();
1299 }
1300 if raw.is_empty() && anchored && directory_only {
1303 raw = "*".to_owned();
1304 directory_only = false;
1305 } else if raw.is_empty() {
1306 return None;
1307 }
1308
1309 let has_slash = raw.contains('/');
1310 Some(Self {
1311 negative,
1312 directory_only,
1313 anchored,
1314 has_slash,
1315 body: raw,
1316 })
1317 }
1318}
1319
1320fn sparse_pattern_matches(p: &SparsePattern, pathname: &str, as_directory: bool) -> bool {
1326 if p.directory_only && !as_directory {
1332 return false;
1333 }
1334 if p.directory_only && p.anchored && !p.has_slash && p.body == "*" {
1338 let trimmed = pathname.trim_end_matches('/');
1339 return trimmed.contains('/') || as_directory;
1340 }
1341 if !p.has_slash && !p.anchored {
1342 let pat = p.body.as_bytes();
1343 sparse_unanchored_basename_matches(&p.body, pathname)
1344 || wildmatch(pat, pathname.as_bytes(), WM_PATHNAME)
1345 || pathname
1346 .split('/')
1347 .any(|comp| wildmatch(pat, comp.as_bytes(), 0))
1348 } else if p.anchored && !p.has_slash && p.body == "*" && !p.directory_only {
1349 !pathname.contains('/')
1353 } else {
1354 wildmatch(p.body.as_bytes(), pathname.as_bytes(), WM_PATHNAME)
1355 }
1356}
1357
1358fn sparse_pattern_matches_path(p: &SparsePattern, pathname: &str) -> bool {
1359 sparse_pattern_matches(p, pathname, false)
1360}
1361
1362#[must_use]
1364pub fn parse_sparse_patterns_from_blob(content: &str) -> Vec<String> {
1365 let mut patterns = Vec::new();
1366 for line in content.lines() {
1367 let t = line.trim_end_matches('\r');
1368 if t.is_empty() || t.starts_with('#') {
1369 continue;
1370 }
1371 patterns.push(t.to_owned());
1372 }
1373 patterns
1374}
1375
1376fn sparse_list_last_match(
1377 pathname: &str,
1378 as_directory: bool,
1379 parsed: &[SparsePattern],
1380) -> Option<bool> {
1381 for p in parsed.iter().rev() {
1382 if sparse_pattern_matches(p, pathname, as_directory) {
1383 return Some(!p.negative);
1384 }
1385 }
1386 None
1387}
1388
1389#[must_use]
1395pub fn path_in_sparse_checkout(path: &str, lines: &[String], work_tree: Option<&Path>) -> bool {
1396 ParsedSparsePatterns::parse(lines).includes(path, work_tree)
1397}
1398
1399#[derive(Debug, Clone)]
1401pub(crate) struct ParsedSparsePatterns(Vec<SparsePattern>);
1402
1403impl ParsedSparsePatterns {
1404 pub(crate) fn parse(lines: &[String]) -> Self {
1406 Self(
1407 lines
1408 .iter()
1409 .filter_map(|l| SparsePattern::from_line(l))
1410 .collect(),
1411 )
1412 }
1413
1414 pub(crate) fn includes(&self, path: &str, work_tree: Option<&Path>) -> bool {
1416 if path.is_empty() || self.0.is_empty() {
1417 return true;
1418 }
1419
1420 let mut end = path.len();
1421 let mut as_directory = false;
1422
1423 loop {
1424 let pathname = &path[..end];
1425 let dtype_is_dir =
1426 work_tree.is_some_and(|wt| wt.join(pathname).is_dir()) || as_directory;
1427
1428 match sparse_list_last_match(pathname, dtype_is_dir, &self.0) {
1429 Some(true) => return true,
1430 Some(false) => return false,
1431 None => {
1432 let Some(slash) = path[..end].rfind('/') else {
1433 return false;
1436 };
1437 end = slash;
1438 as_directory = true;
1439 }
1440 }
1441 }
1442 }
1443}
1444
1445#[must_use]
1453pub fn path_matches_sparse_pattern_list(pathname: &str, lines: &[String]) -> Option<bool> {
1454 let parsed: Vec<SparsePattern> = lines
1455 .iter()
1456 .filter_map(|l| SparsePattern::from_line(l))
1457 .collect();
1458 if parsed.is_empty() {
1459 return None;
1460 }
1461 for p in parsed.iter().rev() {
1462 if sparse_pattern_matches_path(p, pathname) {
1463 return Some(!p.negative);
1464 }
1465 }
1466 None
1467}
1468
1469fn sparse_unanchored_basename_matches(pat: &str, path: &str) -> bool {
1470 let basename = path.rsplit('/').next().unwrap_or(path);
1471 if basename == pat {
1472 return true;
1473 }
1474 if let Some(rest) = basename.strip_prefix(pat) {
1475 return rest.starts_with('.') || rest.starts_with('/');
1476 }
1477 if path == pat {
1478 return true;
1479 }
1480 path.starts_with(&format!("{pat}/"))
1481}
1482
1483#[must_use]
1485pub fn submodule_containing_path(repo_rel_path: &str, index: &Index) -> Option<String> {
1486 let bytes = repo_rel_path.as_bytes();
1487 if bytes.is_empty() {
1488 return None;
1489 }
1490 let mut best: Option<String> = None;
1491 let mut end = 0usize;
1492 while end < bytes.len() {
1493 let Some(slash) = bytes[end..].iter().position(|&b| b == b'/') else {
1494 break;
1495 };
1496 end += slash;
1497 if let Some(entry) = index.get(&bytes[..end], 0) {
1498 if entry.mode == MODE_GITLINK {
1499 best = Some(String::from_utf8_lossy(&bytes[..end]).into_owned());
1500 }
1501 }
1502 end += 1;
1503 }
1504 best
1505}
1506
1507pub fn normalize_repo_relative(repo: &Repository, cwd: &Path, path: &str) -> Result<String> {
1519 let Some(work_tree) = &repo.work_tree() else {
1520 return Err(Error::PathError(
1521 "this operation must be run in a work tree".to_owned(),
1522 ));
1523 };
1524 if path.starts_with(':') {
1525 return Ok(path.to_owned());
1526 }
1527 let query_as_dir = path.ends_with('/');
1528 let input = Path::new(path);
1529 let combined = if input.is_absolute() {
1530 input.to_path_buf()
1531 } else {
1532 cwd.join(input)
1533 };
1534 let normalized = normalize_path(&combined);
1535 let mut rel = match normalized.strip_prefix(work_tree) {
1539 Ok(relative) => crate::git_path::path_to_git_slash(relative),
1540 Err(_) => {
1541 crate::git_path::strip_worktree_prefix(&normalized, work_tree).ok_or_else(|| {
1542 Error::PathError(format!("path '{path}' is outside repository work tree"))
1543 })?
1544 }
1545 };
1546 if query_as_dir && !rel.is_empty() && !rel.ends_with('/') {
1547 rel.push('/');
1548 }
1549 Ok(rel)
1550}
1551
1552fn normalize_path(path: &Path) -> PathBuf {
1553 let mut out = PathBuf::new();
1554 for component in path.components() {
1555 match component {
1556 Component::CurDir => {}
1557 Component::ParentDir => {
1558 out.pop();
1559 }
1560 other => out.push(other.as_os_str()),
1561 }
1562 }
1563 out
1564}
1565
1566#[cfg(test)]
1567mod normalization_tests {
1568 use super::*;
1569
1570 #[test]
1571 fn bare_repository_queries_return_a_path_error() {
1572 let dir = tempfile::tempdir().unwrap();
1573 let repo = crate::repo::init_repository(
1574 dir.path(),
1575 true,
1576 "main",
1577 None,
1578 crate::RefStorageFormat::Files,
1579 )
1580 .unwrap();
1581 assert!(matches!(
1582 normalize_repo_relative(&repo, dir.path(), "missing/file"),
1583 Err(Error::PathError(_))
1584 ));
1585 }
1586}
1587
1588#[cfg(test)]
1589mod sparse_checkout_tests {
1590 use super::*;
1591
1592 #[test]
1593 fn non_cone_default_init_patterns() {
1594 let lines = vec!["/*".into(), "!/*/".into()];
1595 assert!(path_in_sparse_checkout("a", &lines, None));
1596 assert!(!path_in_sparse_checkout("folder1/a", &lines, None));
1597 let wt = std::env::temp_dir().join("grit-sparse-wt-test");
1598 let _ = std::fs::create_dir_all(wt.join("folder1"));
1599 let _ = std::fs::write(wt.join("a"), b"x");
1600 assert!(!path_in_sparse_checkout("folder1/a", &lines, Some(&wt)));
1601 assert!(!path_in_sparse_checkout("folder1", &lines, Some(&wt)));
1602 }
1603
1604 #[test]
1605 fn unanchored_glob_matches_path_components() {
1606 let lines = vec!["/*".into(), "!/*/".into(), "*folder*".into()];
1607 assert!(path_in_sparse_checkout("folder1/a", &lines, None));
1608 assert!(path_in_sparse_checkout("folder2/a", &lines, None));
1609 assert!(!path_in_sparse_checkout("deep/a", &lines, None));
1610 }
1611}
1612
1613#[cfg(test)]
1614mod gitignore_glob_tests {
1615 use super::*;
1616
1617 fn pathname_pattern_matches(pattern: &str, text: &str) -> bool {
1618 pattern_matches_kind(
1619 &classify_ignore_pattern(pattern),
1620 pattern,
1621 text,
1622 true,
1623 false,
1624 )
1625 }
1626
1627 fn rule(line: &str, base_dir: &str) -> IgnoreRule {
1628 parse_rule_line(line, 1, IgnoreSource::InfoExclude, base_dir).unwrap()
1629 }
1630
1631 #[test]
1632 fn lines_lose_at_most_one_trailing_cr() {
1633 let rules = |content: &str| {
1634 parse_gitignore_content(content, IgnoreSource::InfoExclude, "", &mut Vec::new())
1635 .unwrap()
1636 };
1637 let [crlf] = rules("a\r\n").try_into().unwrap();
1638 assert!(rule_matches(&crlf, "a", false, false));
1639 let [double_cr] = rules("a\r\r\n").try_into().unwrap();
1640 assert!(rule_matches(&double_cr, "a\r", false, false));
1641 assert!(!rule_matches(&double_cr, "a", false, false));
1642 let [leading_cr] = rules("\ra\n").try_into().unwrap();
1643 assert!(rule_matches(&leading_cr, "\ra", false, false));
1644 assert!(rules("a\nb").len() == 2);
1645 }
1646
1647 #[test]
1648 fn every_pattern_kind_folds_ascii_case_only_with_ignore_case() {
1649 for (pattern, kind, path) in [
1650 ("README", "literal", "readme"),
1651 ("Target*", "prefix", "target-debug"),
1652 ("*.LOG", "suffix", "debug.log"),
1653 ("[Dd]EBUG?.txt", "wild", "debug1.TXT"),
1654 ] {
1655 let r = rule(pattern, "");
1656 assert!(!rule_matches(&r, path, false, false), "{kind}: {pattern}");
1657 assert!(rule_matches(&r, path, false, true), "{kind}: {pattern}");
1658 assert!(rule_matches(&r, &format!("sub/{path}"), false, true));
1659 }
1660 assert!(!rule_matches(&rule("*.LOG", ""), "x.lag", false, true));
1661 assert!(!rule_matches(&rule("Target*", ""), "tar", false, true));
1662 assert!(!rule_matches(&rule("*.LOG", ""), "LOG", false, true));
1663 assert!(!rule_matches(&rule("*É", ""), "é", false, true));
1665 assert!(!rule_matches(&rule("Über", ""), "über", false, true));
1666 assert!(rule_matches(&rule("Über", ""), "ÜBER", false, true));
1667 }
1668
1669 #[test]
1670 fn directory_and_anchored_rules_fold_case() {
1671 let dir_only = rule("Build/", "");
1672 assert!(rule_matches(&dir_only, "build", true, true));
1673 assert!(rule_matches(&dir_only, "src/BUILD", true, true));
1674 assert!(!rule_matches(&dir_only, "build", true, false));
1675 assert!(!rule_matches(&dir_only, "build", false, true));
1676 let anchored = rule("/Docs/*.PDF", "");
1677 assert!(rule_matches(&anchored, "docs/manual.pdf", false, true));
1678 assert!(!rule_matches(&anchored, "docs/sub/manual.pdf", false, true));
1679 assert!(!rule_matches(&anchored, "docs/manual.pdf", false, false));
1680 }
1681
1682 #[test]
1683 fn nested_gitignore_base_dir_folds_case() {
1684 let nested = rule("*.tmp", "Sub/Dir");
1685 assert!(rule_matches(&nested, "Sub/Dir/a.tmp", false, false));
1686 assert!(!rule_matches(&nested, "sub/dir/a.tmp", false, false));
1687 assert!(rule_matches(&nested, "sub/dir/a.tmp", false, true));
1688 assert!(!rule_matches(&nested, "sub/dirx/a.tmp", false, true));
1689 assert!(!rule_matches(&nested, "sub/di", false, true));
1690 assert_eq!(strip_base("Sub", "sub", true), Some(""));
1691 assert_eq!(strip_base("Sub", "SUB/x", true), Some("x"));
1692 assert_eq!(strip_base("Sub", "subway/x", true), None);
1693 assert_eq!(strip_base("Sub", "sub/x", false), None);
1694 }
1695
1696 #[test]
1697 fn precompiled_literal_prefix_suffix_patterns() {
1698 assert!(matches!(
1699 classify_ignore_pattern("build"),
1700 IgnorePatternKind::Literal(_)
1701 ));
1702 assert!(matches!(
1703 classify_ignore_pattern("target*"),
1704 IgnorePatternKind::Prefix(_)
1705 ));
1706 assert!(matches!(
1707 classify_ignore_pattern("*.o"),
1708 IgnorePatternKind::Suffix(_)
1709 ));
1710 assert!(matches!(
1711 classify_ignore_pattern("a*b"),
1712 IgnorePatternKind::Wild
1713 ));
1714 assert!(matches!(
1715 classify_ignore_pattern(r"foo\ bar"),
1716 IgnorePatternKind::Wild
1717 ));
1718 assert!(matches!(
1719 classify_ignore_pattern("foo/*"),
1720 IgnorePatternKind::Wild
1721 ));
1722 assert!(!pathname_pattern_matches("foo/*", "foo/bar/baz"));
1723 assert!(rule_body_matches(
1724 &IgnoreRule {
1725 source: IgnoreSource::InfoExclude,
1726 line_number: 1,
1727 pattern_text: "*.log".into(),
1728 negative: false,
1729 directory_only: false,
1730 anchored: false,
1731 has_slash: false,
1732 body: "*.log".into(),
1733 base_dir: String::new(),
1734 match_kind: IgnorePatternKind::Suffix(".log".into()),
1735 },
1736 "app.log",
1737 false,
1738 false
1739 ));
1740 }
1741
1742 #[test]
1743 fn dir_star_extension_matches_nested_path() {
1744 assert!(!pathname_pattern_matches("doc/*.pdf", "doc/sub/manual.pdf"));
1746 assert!(pathname_pattern_matches("doc/*.pdf", "doc/manual.pdf"));
1747 assert!(!pathname_pattern_matches("doc/*.pdf", "other/manual.pdf"));
1748 assert!(pathname_pattern_matches(
1749 "doc/**/*.pdf",
1750 "doc/sub/manual.pdf"
1751 ));
1752 }
1753
1754 #[test]
1755 fn dir_star_extension_unexpanded_when_parent_has_glob() {
1756 assert!(!pathname_pattern_matches("*/foo/*.pdf", "a/foo/sub/x.pdf"));
1757 }
1758
1759 #[test]
1760 fn nested_dir_star_extension() {
1761 assert!(!pathname_pattern_matches("foo/bar/*.c", "foo/bar/baz/x.c"));
1762 }
1763}
1764
1765#[cfg(all(test, unix))]
1766mod per_directory_gitignore_tests {
1767 use super::*;
1768 use crate::repo::init_repository;
1769 use crate::RefStorageFormat;
1770 use std::os::unix::ffi::OsStringExt;
1771 use std::os::unix::fs::symlink;
1772 use tempfile::TempDir;
1773
1774 fn ignored_with_warnings(repo: &Repository, path: &str) -> (bool, Vec<String>) {
1775 let mut matcher = IgnoreMatcher::from_repository(repo).unwrap();
1776 let ignored = matcher.is_path_ignored(repo, None, path, false).unwrap();
1777 (ignored, matcher.take_warnings())
1778 }
1779
1780 #[test]
1781 fn symlinked_gitignore_is_skipped_with_warning_and_plain_one_applies() {
1782 let dir = TempDir::new().unwrap();
1783 let root = dir.path();
1784 let repo = init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
1785 fs::create_dir_all(root.join("plain")).unwrap();
1786 fs::create_dir_all(root.join("to_file")).unwrap();
1787 fs::create_dir_all(root.join("to_dir")).unwrap();
1788 fs::create_dir_all(root.join("none")).unwrap();
1789 fs::write(root.join("plain/.gitignore"), "*.log\n").unwrap();
1790 fs::write(root.join("rules"), "*.log\n").unwrap();
1791 symlink(root.join("rules"), root.join("to_file/.gitignore")).unwrap();
1792 symlink(root.join("plain"), root.join("to_dir/.gitignore")).unwrap();
1793 fs::create_dir_all(root.join("dangling")).unwrap();
1794 symlink(root.join("no-such-rules"), root.join("dangling/.gitignore")).unwrap();
1795 fs::create_dir_all(root.join("to_fifo")).unwrap();
1796 let fifo = std::ffi::CString::new(root.join("fifo").into_os_string().into_vec()).unwrap();
1797 assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o644) }, 0);
1799 symlink(root.join("fifo"), root.join("to_fifo/.gitignore")).unwrap();
1800
1801 assert_eq!(ignored_with_warnings(&repo, "plain/a.log"), (true, vec![]));
1802 assert_eq!(ignored_with_warnings(&repo, "none/a.log"), (false, vec![]));
1803 let (done_tx, done_rx) = std::sync::mpsc::channel();
1804 let fifo_root = root.to_path_buf();
1805 std::thread::spawn(move || {
1806 let options = crate::environment::RepositoryOptions::empty().with_work_tree(&fifo_root);
1807 let repo = Repository::open(&fifo_root.join(".git"), &options).unwrap();
1808 let _ = done_tx.send(ignored_with_warnings(&repo, "to_fifo/a.log"));
1809 });
1810 let (ignored, warnings) = done_rx
1811 .recv_timeout(std::time::Duration::from_secs(5))
1812 .expect("a .gitignore symlink to a FIFO must be rejected without opening it");
1813 assert!(!ignored);
1814 assert_eq!(warnings.len(), 1, "{warnings:?}");
1815 for dir in ["to_file", "to_dir", "dangling"] {
1816 let (ignored, warnings) = ignored_with_warnings(&repo, &format!("{dir}/a.log"));
1817 assert!(!ignored, "{dir}: symlinked .gitignore must not apply");
1818 assert_eq!(warnings.len(), 1, "{dir}: {warnings:?}");
1819 assert!(warnings[0].contains(&format!("{dir}/.gitignore")));
1820 }
1821 }
1822}
1823
1824#[cfg(test)]
1825mod ignore_case_matcher_tests {
1826 use super::*;
1827 use crate::repo::init_repository;
1828 use crate::RefStorageFormat;
1829 use tempfile::TempDir;
1830
1831 fn issue_repo(ignore_case: bool) -> (TempDir, Repository) {
1832 let dir = TempDir::new().unwrap();
1833 let root = dir.path();
1834 init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
1835 let mut config = fs::read_to_string(root.join(".git/config")).unwrap();
1836 config.push_str(&format!("[core]\n\tignorecase = {ignore_case}\n"));
1837 fs::write(root.join(".git/config"), config).unwrap();
1838 fs::write(root.join(".gitignore"), "Build/\n*.LOG\n!keep.log\n").unwrap();
1839 fs::create_dir_all(root.join("build")).unwrap();
1840 for file in ["build/x.o", "debug.log", "keep.log", "README"] {
1841 fs::write(root.join(file), "x\n").unwrap();
1842 }
1843 let options = crate::environment::RepositoryOptions::empty().with_work_tree(root);
1844 let repo = Repository::open(&root.join(".git"), &options).unwrap();
1845 (dir, repo)
1846 }
1847
1848 fn ignored(matcher: &mut IgnoreMatcher, repo: &Repository, path: &str, is_dir: bool) -> bool {
1849 let fast = matcher.is_path_ignored(repo, None, path, is_dir).unwrap();
1850 let (verbose, _) = matcher.check_path(repo, None, path, is_dir).unwrap();
1851 assert_eq!(fast, verbose, "{path}");
1852 fast
1853 }
1854
1855 #[test]
1856 fn from_repository_honors_core_ignorecase() {
1857 let (_dir, repo) = issue_repo(true);
1858 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
1859 assert!(matcher.ignore_case());
1860 assert!(ignored(&mut matcher, &repo, "build", true));
1861 assert!(ignored(&mut matcher, &repo, "build/x.o", false));
1862 assert!(ignored(&mut matcher, &repo, "debug.log", false));
1863 assert!(!ignored(&mut matcher, &repo, "keep.log", false));
1864 assert!(!ignored(&mut matcher, &repo, "KEEP.LOG", false));
1865 assert!(!ignored(&mut matcher, &repo, "README", false));
1866 let (_, matched) = matcher.check_path(&repo, None, "build/x.o", false).unwrap();
1867 assert_eq!(matched.unwrap().pattern_text, "Build/");
1868 }
1869
1870 #[test]
1871 fn case_sensitive_by_default_and_overridable() {
1872 let (_dir, repo) = issue_repo(false);
1873 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
1874 assert!(!matcher.ignore_case());
1875 assert!(!ignored(&mut matcher, &repo, "build/x.o", false));
1876 assert!(!ignored(&mut matcher, &repo, "debug.log", false));
1877 assert!(ignored(&mut matcher, &repo, "Build/x.o", false));
1878
1879 let mut folded = matcher.with_ignore_case(true);
1880 assert!(ignored(&mut folded, &repo, "build/x.o", false));
1881 assert!(ignored(&mut folded, &repo, "debug.log", false));
1882 assert!(!IgnoreMatcher::default().ignore_case());
1883 }
1884}
1885
1886#[cfg(test)]
1887mod index_lookup_scaling_tests {
1888 use super::*;
1889 use crate::index::{index_path_cmp, Index, IndexEntry, MODE_REGULAR};
1890 use crate::objects::ObjectId;
1891 use crate::repo::init_repository;
1892 use crate::RefStorageFormat;
1893 use tempfile::TempDir;
1894
1895 fn dummy_oid() -> ObjectId {
1896 ObjectId::from_bytes(&[0u8; 20]).unwrap()
1897 }
1898
1899 fn make_entry(path: &str) -> IndexEntry {
1900 IndexEntry {
1901 ctime_sec: 0,
1902 ctime_nsec: 0,
1903 mtime_sec: 0,
1904 mtime_nsec: 0,
1905 dev: 0,
1906 ino: 0,
1907 mode: MODE_REGULAR,
1908 uid: 0,
1909 gid: 0,
1910 size: 0,
1911 oid: dummy_oid(),
1912 flags: path.len().min(0xFFF) as u16,
1913 flags_extended: None,
1914 path: path.as_bytes().to_vec(),
1915 base_index_pos: 0,
1916 }
1917 }
1918
1919 #[test]
1920 fn tracked_check_path_index_lookups_sublinear_in_index_size() {
1921 const N: usize = 100_000;
1922 const M: usize = 1_000;
1923 let dir = TempDir::new().unwrap();
1924 let repo =
1925 init_repository(dir.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1926 let mut index = Index::new();
1927 for i in 0..N {
1928 index.add_or_replace(make_entry(&format!("tracked/{i:06}.txt")));
1929 }
1930 let mut matcher = IgnoreMatcher::default();
1931 index_path_cmp::reset();
1932 for i in 0..M {
1933 let path = format!("untracked/{i:06}.txt");
1934 let (ignored, _) = matcher
1935 .check_path(&repo, Some(&index), &path, false)
1936 .unwrap();
1937 assert!(!ignored, "untracked path must not be ignored");
1938 }
1939 let cmps = index_path_cmp::take();
1940 let log_n = (N as f64).log2().ceil() as usize;
1941 let bound = 100 * M * log_n;
1942 assert!(
1943 cmps < bound,
1944 "index path comparisons {cmps} should stay under {bound} (N={N}, M={M})"
1945 );
1946 }
1947}
1948
1949#[cfg(test)]
1950mod pattern_list_tests {
1951 use super::IgnorePatternList;
1952
1953 fn ignored(content: &str, path: &str, is_dir: bool) -> bool {
1954 IgnorePatternList::parse(content.as_bytes(), "")
1955 .is_ignored(path, is_dir, false)
1956 .unwrap_or(false)
1957 }
1958
1959 #[test]
1960 fn basename_patterns_match_last_component_only() {
1961 assert!(ignored("foo\n", "foo", false));
1962 assert!(ignored("foo\n", "dir/foo", false));
1963 assert!(!ignored("foo\n", "foo/bar", false));
1964 assert!(ignored("*.o\n", "a/b/c.o", false));
1965 }
1966
1967 #[test]
1968 fn slash_patterns_match_whole_path() {
1969 assert!(ignored("/foo\n", "foo", false));
1970 assert!(!ignored("/foo\n", "dir/foo", false));
1971 assert!(ignored("dir/foo\n", "dir/foo", false));
1972 assert!(!ignored("dir/foo\n", "x/dir/foo", false));
1973 assert!(ignored("dir/*.o\n", "dir/a.o", false));
1974 assert!(!ignored("dir/*.o\n", "dir/sub/a.o", false));
1975 assert!(ignored("**/foo\n", "a/b/foo", false));
1976 assert!(ignored("a/**/b\n", "a/x/y/b", false));
1977 }
1978
1979 #[test]
1980 fn directory_only_patterns_need_a_directory() {
1981 assert!(ignored("foo/\n", "foo", true));
1982 assert!(!ignored("foo/\n", "foo", false));
1983 assert!(!ignored("foo/\n", "foo/bar", false));
1984 }
1985
1986 #[test]
1987 fn last_matching_pattern_wins() {
1988 let list = IgnorePatternList::parse(b"*.log\n!keep.log\n", "");
1989 assert_eq!(list.is_ignored("a.log", false, false), Some(true));
1990 assert_eq!(list.is_ignored("keep.log", false, false), Some(false));
1991 assert_eq!(list.is_ignored("a.txt", false, false), None);
1992 let m = list.last_match("keep.log", false, false).unwrap();
1993 assert_eq!((m.line_number, m.pattern_text.as_str()), (2, "!keep.log"));
1994 }
1995
1996 #[test]
1997 fn comments_blank_lines_and_trailing_spaces() {
1998 let list = IgnorePatternList::parse(b"\xef\xbb\xbf# comment\n\nfoo \nbar\\ \n\\#x\n", "");
1999 assert_eq!(list.len(), 3);
2000 assert!(ignored("foo \n", "foo", false));
2001 assert!(list.is_ignored("bar ", false, false).unwrap());
2002 assert!(list.is_ignored("#x", false, false).unwrap());
2003 }
2004
2005 #[test]
2006 fn base_directory_scopes_patterns() {
2007 let list = IgnorePatternList::parse(b"foo\n/bar\n", "sub");
2008 assert_eq!(list.is_ignored("sub/foo", false, false), Some(true));
2009 assert_eq!(list.is_ignored("sub/x/foo", false, false), Some(true));
2010 assert_eq!(list.is_ignored("sub/bar", false, false), Some(true));
2011 assert_eq!(list.is_ignored("sub/x/bar", false, false), None);
2012 assert_eq!(list.is_ignored("foo", false, false), None);
2013 assert_eq!(list.is_ignored("subfoo", false, false), None);
2014 }
2015
2016 #[test]
2017 fn ignore_case() {
2018 let list = IgnorePatternList::parse(b"*.LOG\nBuild/\n", "");
2019 assert_eq!(list.is_ignored("debug.log", false, false), None);
2020 assert_eq!(list.is_ignored("debug.log", false, true), Some(true));
2021 assert_eq!(list.is_ignored("build", true, true), Some(true));
2022 }
2023}