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