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)]
66enum IgnorePatternKind {
67 Wild,
68 Literal(String),
69 Prefix(String),
70 Suffix(String),
71}
72
73#[derive(Debug, Clone)]
74struct IgnoreRule {
75 source: IgnoreSource,
76 line_number: usize,
77 pattern_text: String,
78 negative: bool,
79 directory_only: bool,
80 anchored: bool,
81 has_slash: bool,
82 body: String,
83 base_dir: String,
84 match_kind: IgnorePatternKind,
85}
86
87#[derive(Debug, Default, Clone)]
89pub struct IgnoreMatcher {
90 cli_rules: Vec<IgnoreRule>,
92 global_rules: Vec<IgnoreRule>,
93 info_rules: Vec<IgnoreRule>,
94 exclude_from_rules: Vec<IgnoreRule>,
96 gitignore_cache: HashMap<String, Vec<IgnoreRule>>,
97 per_directory_name: Option<String>,
99 read_per_directory: bool,
104 pub warnings: Vec<String>,
106 per_dir_dirs_cache: Option<(String, Vec<String>)>,
108 dir_exclude_stack_cache: HashMap<String, Vec<String>>,
110 ignore_case: bool,
112}
113
114impl IgnoreMatcher {
115 pub fn from_repository(repo: &Repository) -> Result<Self> {
129 let ignore_case = crate::crlf::config_ignorecase(repo.config()?.as_ref());
130 Ok(Self {
131 global_rules: load_global_excludes(repo)?,
132 info_rules: load_info_excludes(repo)?,
133 read_per_directory: true,
135 ignore_case,
136 ..Self::default()
137 })
138 }
139
140 #[must_use]
154 pub fn with_ignore_case(mut self, ignore_case: bool) -> Self {
155 self.ignore_case = ignore_case;
156 self
157 }
158
159 #[must_use]
161 pub fn ignore_case(&self) -> bool {
162 self.ignore_case
163 }
164
165 pub fn add_exclude_from_files(&mut self, paths: &[PathBuf], cwd: &Path) -> Result<()> {
169 for path in paths {
170 let resolved = if path.is_absolute() {
171 path.clone()
172 } else {
173 cwd.join(path)
174 };
175 let display = path.display().to_string();
176 let mut more = load_rules_from_file(
177 &resolved,
178 IgnoreSource::CoreExcludesFile(display),
179 String::new(),
180 false,
181 &mut self.warnings,
182 )?;
183 self.exclude_from_rules.append(&mut more);
184 }
185 Ok(())
186 }
187
188 pub fn set_per_directory_name(&mut self, name: &str) {
192 self.per_directory_name = Some(name.to_owned());
193 self.read_per_directory = true;
194 self.gitignore_cache.clear();
196 self.per_dir_dirs_cache = None;
197 self.dir_exclude_stack_cache.clear();
198 }
199
200 pub fn add_cli_excludes(&mut self, patterns: &[String]) {
202 for pat in patterns {
203 if let Some(rule) = parse_rule_line(
204 pat,
205 1,
206 IgnoreSource::PerDirectory("--exclude option".into()),
207 "",
208 ) {
209 self.cli_rules.push(rule);
210 }
211 }
212 }
213
214 #[must_use]
216 pub fn take_warnings(&mut self) -> Vec<String> {
217 std::mem::take(&mut self.warnings)
218 }
219
220 pub fn is_path_ignored(
225 &mut self,
226 repo: &Repository,
227 index: Option<&Index>,
228 repo_rel_path: &str,
229 is_dir: bool,
230 ) -> Result<bool> {
231 if is_tracked(index, repo_rel_path) {
232 return Ok(false);
233 }
234 let per_dir_dirs = self.ensure_per_dir_rules_loaded(repo, index, repo_rel_path)?;
235 let per_dir_groups = self.cached_rule_slices_for_dirs(&per_dir_dirs);
236 if !self.has_any_exclude_rules(&per_dir_groups) {
237 return Ok(false);
238 }
239 if self
240 .first_excluded_ancestor(repo_rel_path, &per_dir_groups)
241 .is_some()
242 {
243 return Ok(true);
244 }
245 Ok(self
246 .evaluate_against_groups(repo_rel_path, is_dir, &per_dir_groups)
247 .0)
248 }
249
250 pub fn check_path(
270 &mut self,
271 repo: &Repository,
272 index: Option<&Index>,
273 repo_rel_path: &str,
274 is_dir: bool,
275 ) -> Result<(bool, Option<IgnoreMatch>)> {
276 if is_tracked(index, repo_rel_path) {
277 return Ok((false, None));
278 }
279
280 let per_dir_dirs = self.ensure_per_dir_rules_loaded(repo, index, repo_rel_path)?;
289 let per_dir_groups = self.cached_rule_slices_for_dirs(&per_dir_dirs);
290
291 if let Some(ancestor_match) = self.first_excluded_ancestor(repo_rel_path, &per_dir_groups) {
300 return Ok((true, Some(ancestor_match)));
301 }
302
303 let (ignored, matched) =
304 self.evaluate_against_groups(repo_rel_path, is_dir, &per_dir_groups);
305
306 let all_rules: Vec<&IgnoreRule> = self.flatten_groups(&per_dir_groups);
308 let matched = refine_match_for_check_ignore_verbose(
309 repo_rel_path,
310 is_dir,
311 ignored,
312 matched,
313 &all_rules,
314 self.ignore_case,
315 );
316
317 Ok((ignored, matched))
318 }
319
320 fn evaluate_against_groups(
329 &self,
330 path: &str,
331 is_dir: bool,
332 per_dir_groups: &[&[IgnoreRule]],
333 ) -> (bool, Option<IgnoreMatch>) {
334 let groups = self.build_groups(per_dir_groups);
335 for group in &groups {
336 let mut group_match: Option<&&IgnoreRule> = None;
337 for rule in group {
338 if rule_matches(rule, path, is_dir, self.ignore_case) {
339 group_match = Some(rule);
340 }
341 }
342 if let Some(rule) = group_match {
343 return (
344 !rule.negative,
345 Some(IgnoreMatch {
346 source: rule.source.clone(),
347 line_number: rule.line_number,
348 pattern_text: rule.pattern_text.clone(),
349 negative: rule.negative,
350 }),
351 );
352 }
353 }
354 (false, None)
355 }
356
357 fn flatten_groups<'a>(&'a self, per_dir_groups: &[&'a [IgnoreRule]]) -> Vec<&'a IgnoreRule> {
360 self.build_groups(per_dir_groups)
361 .into_iter()
362 .flatten()
363 .collect()
364 }
365
366 fn build_groups<'a>(&'a self, per_dir_groups: &[&'a [IgnoreRule]]) -> Vec<Vec<&'a IgnoreRule>> {
369 let cmdl_rules: Vec<&IgnoreRule> = self.cli_rules.iter().collect();
370 let mut dir_groups: Vec<Vec<&IgnoreRule>> = Vec::new();
371 for rules in per_dir_groups.iter().rev() {
372 dir_groups.push(rules.iter().collect());
373 }
374 let file_rules: Vec<&IgnoreRule> = self
375 .exclude_from_rules
376 .iter()
377 .chain(self.global_rules.iter())
378 .chain(self.info_rules.iter())
379 .collect();
380
381 let mut groups: Vec<Vec<&IgnoreRule>> = Vec::new();
382 groups.push(cmdl_rules);
383 groups.extend(dir_groups);
384 groups.push(file_rules);
385 groups
386 }
387
388 fn first_excluded_ancestor(
397 &self,
398 repo_rel_path: &str,
399 per_dir_groups: &[&[IgnoreRule]],
400 ) -> Option<IgnoreMatch> {
401 let parent = parent_dir(repo_rel_path);
402 if parent.is_empty() {
403 return None;
404 }
405 let mut cur = String::new();
406 for (depth, segment) in parent.split('/').enumerate() {
407 if !cur.is_empty() {
408 cur.push('/');
409 }
410 cur.push_str(segment);
411 let upper = (depth + 1).min(per_dir_groups.len());
415 let slice = &per_dir_groups[..upper];
416 let (ignored, matched) = self.evaluate_against_groups(&cur, true, slice);
417 if ignored {
418 return matched;
419 }
420 }
421 None
422 }
423
424 fn has_any_exclude_rules(&self, per_dir_groups: &[&[IgnoreRule]]) -> bool {
429 !self.cli_rules.is_empty()
430 || !self.exclude_from_rules.is_empty()
431 || !self.global_rules.is_empty()
432 || !self.info_rules.is_empty()
433 || per_dir_groups.iter().any(|g| !g.is_empty())
434 }
435
436 pub fn note_directory_entered(
440 &mut self,
441 repo: &Repository,
442 index: Option<&Index>,
443 dir_rel: &str,
444 ) -> Result<()> {
445 if !self.read_per_directory {
446 return Ok(());
447 }
448 if self.gitignore_cache.contains_key(dir_rel) {
449 return Ok(());
450 }
451 let per_dir_name = self.per_directory_name.clone();
452 let rules = load_gitignore_for_dir(
453 repo,
454 index,
455 dir_rel,
456 per_dir_name.as_deref(),
457 &mut self.warnings,
458 )?;
459 self.gitignore_cache.insert(dir_rel.to_owned(), rules);
460 Ok(())
461 }
462
463 fn ensure_per_dir_rules_loaded(
464 &mut self,
465 repo: &Repository,
466 index: Option<&Index>,
467 repo_rel_path: &str,
468 ) -> Result<Vec<String>> {
469 if !self.read_per_directory {
470 return Ok(Vec::new());
471 }
472 let parent = parent_dir(repo_rel_path);
473 let dirs = match self.per_dir_dirs_cache.as_ref() {
474 Some((cached, cached_dirs)) if cached == parent => cached_dirs.clone(),
475 _ => {
476 if let Some(cached_dirs) = self.dir_exclude_stack_cache.get(parent) {
477 self.per_dir_dirs_cache = Some((parent.to_string(), cached_dirs.clone()));
478 cached_dirs.clone()
479 } else {
480 let mut dirs = Vec::new();
481 dirs.push(String::new());
482 if !parent.is_empty() {
483 let mut cur = String::new();
484 for segment in parent.split('/') {
485 if !cur.is_empty() {
486 cur.push('/');
487 }
488 cur.push_str(segment);
489 if worktree_path_is_existing_file(repo, &cur) {
490 break;
493 }
494 dirs.push(cur.clone());
495 }
496 }
497 self.per_dir_dirs_cache = Some((parent.to_string(), dirs.clone()));
498 self.dir_exclude_stack_cache
499 .insert(parent.to_string(), dirs.clone());
500 dirs
501 }
502 }
503 };
504
505 let per_dir_name = self.per_directory_name.clone();
506 for dir in &dirs {
507 if !self.gitignore_cache.contains_key(dir) {
508 let rules = load_gitignore_for_dir(
509 repo,
510 index,
511 dir,
512 per_dir_name.as_deref(),
513 &mut self.warnings,
514 )?;
515 self.gitignore_cache.insert(dir.clone(), rules);
516 }
517 }
518 Ok(dirs)
519 }
520
521 fn cached_rule_slices_for_dirs(&self, dirs: &[String]) -> Vec<&[IgnoreRule]> {
522 dirs.iter()
523 .map(|dir| {
524 self.gitignore_cache
525 .get(dir)
526 .map_or(&[] as &[IgnoreRule], Vec::as_slice)
527 })
528 .collect()
529 }
530}
531
532fn load_global_excludes(repo: &Repository) -> Result<Vec<IgnoreRule>> {
533 let config = repo.config()?;
534 let env = repo.environment();
535 let (resolved, display_path) = if let Some(raw_path) = config.get("core.excludesfile") {
536 let expanded = parse_path(env, &raw_path);
537 let resolved = if Path::new(&expanded).is_absolute() {
538 PathBuf::from(&expanded)
539 } else if let Some(work_tree) = &repo.work_tree() {
540 work_tree.join(&expanded)
541 } else {
542 repo.git_dir().join(&expanded)
543 };
544 (resolved, raw_path)
545 } else if let Some(path) = env.default_xdg_config_git_file("ignore") {
546 let display = path.display().to_string();
547 (path, display)
548 } else {
549 return Ok(Vec::new());
550 };
551
552 let mut sink = Vec::new();
553 load_rules_from_file(
554 &resolved,
555 IgnoreSource::CoreExcludesFile(display_path),
556 String::new(),
557 false,
558 &mut sink,
559 )
560}
561
562fn load_info_excludes(repo: &Repository) -> Result<Vec<IgnoreRule>> {
563 let path = repo.git_dir().join("info/exclude");
564 let mut sink = Vec::new();
565 load_rules_from_file(
566 &path,
567 IgnoreSource::InfoExclude,
568 String::new(),
569 false,
570 &mut sink,
571 )
572}
573
574fn load_gitignore_for_dir(
575 repo: &Repository,
576 index: Option<&Index>,
577 dir: &str,
578 per_dir_name: Option<&str>,
579 warnings: &mut Vec<String>,
580) -> Result<Vec<IgnoreRule>> {
581 let Some(work_tree) = &repo.work_tree() else {
582 return Ok(Vec::new());
583 };
584 let file_name = per_dir_name.unwrap_or(".gitignore");
585 let path = if dir.is_empty() {
586 work_tree.join(file_name)
587 } else {
588 work_tree.join(dir).join(file_name)
589 };
590 let source_display = if dir.is_empty() {
591 file_name.to_owned()
592 } else {
593 format!("{dir}/{file_name}")
594 };
595 let source = IgnoreSource::PerDirectory(source_display.clone());
596 let rel_key = if dir.is_empty() {
597 file_name.to_owned()
598 } else {
599 format!("{dir}/{file_name}")
600 };
601
602 match read_in_tree_pattern_file(&path)? {
605 InTreePatternFile::Symlink => {
606 warnings.push(crate::diagnostics::warning_line(&format!(
607 "unable to access '{source_display}': Too many levels of symbolic links"
608 )));
609 return Ok(Vec::new());
610 }
611 InTreePatternFile::Content(content) => {
612 return parse_gitignore_content(&content, source, dir, warnings);
613 }
614 InTreePatternFile::Missing => {}
615 }
616
617 if let Some(ix) = index {
618 if let Some(entry) = ix.get(rel_key.as_bytes(), 0) {
619 if let Ok(obj) = repo.odb().read(&entry.oid) {
620 if obj.kind == ObjectKind::Blob {
621 if let Ok(text) = std::str::from_utf8(&obj.data) {
622 return parse_gitignore_content(text, source, dir, warnings);
623 }
624 }
625 }
626 }
627 }
628
629 Ok(Vec::new())
630}
631
632fn parse_gitignore_content(
633 content: &str,
634 source: IgnoreSource,
635 base_dir: &str,
636 _warnings: &mut Vec<String>,
637) -> Result<Vec<IgnoreRule>> {
638 let mut rules = Vec::new();
639 for (idx, line) in content.lines().enumerate() {
640 let line = if idx == 0 {
641 line.strip_prefix('\u{feff}').unwrap_or(line)
642 } else {
643 line
644 };
645 if let Some(rule) = parse_rule_line(line, idx + 1, source.clone(), base_dir) {
646 rules.push(rule);
647 }
648 }
649 Ok(rules)
650}
651
652fn load_rules_from_file(
653 path: &Path,
654 source: IgnoreSource,
655 base_dir: String,
656 deny_symlink_gitignore: bool,
657 warnings: &mut Vec<String>,
658) -> Result<Vec<IgnoreRule>> {
659 if deny_symlink_gitignore && path.exists() {
660 if let Ok(meta) = fs::symlink_metadata(path) {
661 if meta.file_type().is_symlink() {
662 warnings.push(crate::diagnostics::warning_line(&format!(
663 "unable to access '{}': Too many levels of symbolic links",
664 source
665 )));
666 return Ok(Vec::new());
667 }
668 }
669 }
670 if path.exists() {
671 crate::worktree_rules::record_ignore_pattern_file_read(path);
672 }
673 let Some(content) = read_optional_text(path)? else {
674 return Ok(Vec::new());
675 };
676 parse_gitignore_content(&content, source, &base_dir, warnings)
677}
678
679fn trim_trailing_spaces_git(buf: &mut String) {
684 let mut bytes = std::mem::take(buf).into_bytes();
685 let mut last_space_start: Option<usize> = None;
686 let mut i = 0usize;
687 while i < bytes.len() {
688 match bytes[i] {
689 b' ' => {
690 if last_space_start.is_none() {
691 last_space_start = Some(i);
692 }
693 i += 1;
694 }
695 b'\\' => {
696 last_space_start = None;
697 i += 1;
698 if i < bytes.len() {
699 i += 1;
700 } else {
701 *buf = String::from_utf8_lossy(&bytes).into_owned();
702 return;
703 }
704 }
705 _ => {
706 last_space_start = None;
707 i += 1;
708 }
709 }
710 }
711 if let Some(start) = last_space_start {
712 bytes.truncate(start);
713 }
714 *buf = String::from_utf8_lossy(&bytes).into_owned();
715}
716
717fn parse_rule_line(
718 line: &str,
719 line_number: usize,
720 source: IgnoreSource,
721 base_dir: &str,
722) -> Option<IgnoreRule> {
723 let mut raw_line = line.trim_end_matches('\r').to_owned();
724 trim_trailing_spaces_git(&mut raw_line);
725 if raw_line.is_empty() || raw_line.starts_with('#') {
726 return None;
727 }
728
729 let pattern_text = raw_line.clone();
730 let mut raw = raw_line;
731
732 let mut negative = false;
733 if let Some(rest) = raw.strip_prefix('!') {
734 negative = true;
735 raw = rest.to_owned();
736 }
737 if raw.is_empty() {
738 return None;
739 }
740
741 let mut anchored = false;
742 if let Some(rest) = raw.strip_prefix('/') {
743 anchored = true;
744 raw = rest.to_owned();
745 }
746 if raw.is_empty() {
747 return None;
748 }
749
750 let mut directory_only = false;
751 if let Some(rest) = raw.strip_suffix('/') {
752 directory_only = true;
753 raw = rest.to_owned();
754 }
755 if raw.is_empty() {
756 return None;
757 }
758
759 let has_slash = raw.contains('/');
760 let body = raw;
761 let match_kind = classify_ignore_pattern(&body);
762 Some(IgnoreRule {
763 source,
764 line_number,
765 pattern_text,
766 negative,
767 directory_only,
768 anchored,
769 has_slash,
770 body,
771 base_dir: base_dir.to_owned(),
772 match_kind,
773 })
774}
775
776fn classify_ignore_pattern(body: &str) -> IgnorePatternKind {
777 if body.contains('\\') || body.contains(['?', '[']) {
780 return IgnorePatternKind::Wild;
781 }
782 if !body.contains('*') {
783 return IgnorePatternKind::Literal(body.to_owned());
784 }
785 if let Some(rest) = body.strip_prefix('*') {
786 if !rest.contains('*') && !rest.contains('/') {
787 return IgnorePatternKind::Suffix(rest.to_owned());
788 }
789 }
790 if let Some(rest) = body.strip_suffix('*') {
791 if !rest.contains('*') && !rest.contains('/') {
792 return IgnorePatternKind::Prefix(rest.to_owned());
793 }
794 }
795 IgnorePatternKind::Wild
796}
797
798fn base_dir_depth(base: &str) -> usize {
799 if base.is_empty() {
800 return 0;
801 }
802 base.split('/').count()
803}
804
805fn refine_match_for_check_ignore_verbose(
808 repo_rel_path: &str,
809 is_dir: bool,
810 ignored: bool,
811 matched: Option<IgnoreMatch>,
812 rules: &[&IgnoreRule],
813 ignore_case: bool,
814) -> Option<IgnoreMatch> {
815 let m = matched?;
816 if m.negative {
817 return Some(m);
818 }
819 if !ignored {
820 return Some(m);
821 }
822 let mut best: Option<&IgnoreRule> = None;
823 for rule in rules {
824 if !rule.directory_only {
825 continue;
826 }
827 if !rule_matches(rule, repo_rel_path, is_dir, ignore_case) {
828 continue;
829 }
830 match best {
831 None => best = Some(rule),
832 Some(b) if base_dir_depth(&rule.base_dir) < base_dir_depth(&b.base_dir) => {
833 best = Some(rule);
834 }
835 Some(b)
836 if base_dir_depth(&rule.base_dir) == base_dir_depth(&b.base_dir)
837 && rule.line_number < b.line_number =>
838 {
839 best = Some(rule);
840 }
841 _ => {}
842 }
843 }
844 Some(
845 best.map(|r| IgnoreMatch {
846 source: r.source.clone(),
847 line_number: r.line_number,
848 pattern_text: r.pattern_text.clone(),
849 negative: r.negative,
850 })
851 .unwrap_or(m),
852 )
853}
854
855fn rule_matches(rule: &IgnoreRule, repo_rel_path: &str, is_dir: bool, ignore_case: bool) -> bool {
856 if ignore_case {
857 rule_matches_with::<true>(rule, repo_rel_path, is_dir)
858 } else {
859 rule_matches_with::<false>(rule, repo_rel_path, is_dir)
860 }
861}
862
863fn rule_matches_with<const FOLD: bool>(
866 rule: &IgnoreRule,
867 repo_rel_path: &str,
868 is_dir: bool,
869) -> bool {
870 if rule.directory_only && !is_dir {
875 return false;
876 }
877
878 let Some(rel_to_base) = strip_base(&rule.base_dir, repo_rel_path, FOLD) else {
879 return false;
880 };
881
882 if rule.directory_only {
883 if rule.has_slash || rule.anchored {
884 for ancestor in ancestor_dirs(rel_to_base, is_dir) {
885 if rule_body_matches(rule, &ancestor, true, FOLD) {
886 return true;
887 }
888 }
889 return false;
890 }
891 for ancestor in ancestor_dir_basenames(rel_to_base, is_dir) {
892 if rule_body_matches(rule, ancestor, false, FOLD) {
893 return true;
894 }
895 }
896 return false;
897 }
898
899 if rule.has_slash || rule.anchored {
900 return rule_body_matches(rule, rel_to_base, true, FOLD);
901 }
902
903 !rel_to_base.is_empty()
904 && rel_to_base
905 .split('/')
906 .any(|name| rule_body_matches(rule, name, false, FOLD))
907}
908
909fn is_tracked(index: Option<&Index>, repo_rel_path: &str) -> bool {
910 let Some(index) = index else {
911 return false;
912 };
913 if repo_rel_path.ends_with('/') {
916 return false;
917 }
918 index.get(repo_rel_path.as_bytes(), 0).is_some()
919}
920
921fn worktree_path_is_existing_file(repo: &Repository, repo_rel_path: &str) -> bool {
923 let Some(work_tree) = repo.work_tree() else {
924 return false;
925 };
926 let path = work_tree.join(repo_rel_path);
927 path.metadata().map(|m| m.is_file()).unwrap_or(false)
928}
929
930fn path_query_is_dir(repo: &Repository, repo_rel_path: &str) -> bool {
931 if repo_rel_path.ends_with('/') {
932 return true;
933 }
934 let Some(work_tree) = repo.work_tree() else {
935 return false;
936 };
937 let path = work_tree.join(repo_rel_path);
938 path.metadata().map(|m| m.is_dir()).unwrap_or(false)
939}
940
941impl Repository {
942 pub fn ignore_check(&self, paths: &[String], index: &Index) -> Result<Vec<IgnoreCheck>> {
957 let mut matcher = IgnoreMatcher::from_repository(self)?;
958 let mut out = Vec::with_capacity(paths.len());
959 for path in paths {
960 let is_dir = path_query_is_dir(self, path);
961 let (ignored, matched) = matcher.check_path(self, Some(index), path, is_dir)?;
962 out.push(IgnoreCheck {
963 path: path.clone(),
964 ignored,
965 matched,
966 });
967 }
968 Ok(out)
969 }
970}
971
972enum InTreePatternFile {
974 Missing,
975 Symlink,
976 Content(String),
977}
978
979fn read_in_tree_pattern_file(path: &Path) -> Result<InTreePatternFile> {
982 let is_symlink = || fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink());
983 #[cfg(unix)]
984 let opened = {
985 use std::os::unix::fs::OpenOptionsExt;
986 fs::OpenOptions::new()
987 .read(true)
988 .custom_flags(libc::O_NOFOLLOW)
989 .open(path)
990 };
991 #[cfg(not(unix))]
992 let opened = if is_symlink() {
993 return Ok(InTreePatternFile::Symlink);
994 } else {
995 fs::File::open(path)
996 };
997 let mut file = match opened {
998 Ok(file) => file,
999 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1000 return Ok(InTreePatternFile::Missing);
1001 }
1002 Err(_) if is_symlink() => return Ok(InTreePatternFile::Symlink),
1004 Err(err) => return Err(Error::Io(err)),
1005 };
1006 let mut content = String::new();
1007 std::io::Read::read_to_string(&mut file, &mut content).map_err(Error::Io)?;
1008 Ok(InTreePatternFile::Content(content))
1009}
1010
1011fn read_optional_text(path: &Path) -> Result<Option<String>> {
1012 match fs::read_to_string(path) {
1013 Ok(content) => Ok(Some(content)),
1014 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1015 Err(err) => Err(Error::Io(err)),
1016 }
1017}
1018
1019fn strip_base<'a>(base: &str, path: &'a str, ignore_case: bool) -> Option<&'a str> {
1022 if base.is_empty() {
1023 return Some(path);
1024 }
1025 if !starts_with_fold(path, base, ignore_case) {
1026 return None;
1027 }
1028 match path.as_bytes().get(base.len()) {
1029 None => Some(""),
1030 Some(b'/') => path.get(base.len() + 1..),
1032 Some(_) => None,
1033 }
1034}
1035
1036fn eq_fold(text: &str, pattern: &str, ignore_case: bool) -> bool {
1037 if ignore_case {
1038 text.eq_ignore_ascii_case(pattern)
1039 } else {
1040 text == pattern
1041 }
1042}
1043
1044fn starts_with_fold(text: &str, prefix: &str, ignore_case: bool) -> bool {
1045 if !ignore_case {
1046 return text.starts_with(prefix);
1047 }
1048 text.as_bytes()
1049 .get(..prefix.len())
1050 .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_bytes()))
1051}
1052
1053fn ends_with_fold(text: &str, suffix: &str, ignore_case: bool) -> bool {
1054 if !ignore_case {
1055 return text.ends_with(suffix);
1056 }
1057 text.len()
1058 .checked_sub(suffix.len())
1059 .and_then(|start| text.as_bytes().get(start..))
1060 .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix.as_bytes()))
1061}
1062
1063fn parent_dir(path: &str) -> &str {
1064 match path.rsplit_once('/') {
1065 Some((parent, _)) => parent,
1066 None => "",
1067 }
1068}
1069
1070fn ancestor_dirs(path: &str, is_dir: bool) -> Vec<String> {
1071 let mut out = Vec::new();
1072 if path.is_empty() {
1073 return out;
1074 }
1075 let parts: Vec<&str> = path.split('/').collect();
1076 let max = if is_dir {
1077 parts.len()
1078 } else {
1079 parts.len().saturating_sub(1)
1080 };
1081 for idx in 1..=max {
1082 out.push(parts[..idx].join("/"));
1083 }
1084 out
1085}
1086
1087fn ancestor_dir_basenames(path: &str, is_dir: bool) -> Vec<&str> {
1088 let mut out = Vec::new();
1089 let parts: Vec<&str> = if path.is_empty() {
1090 Vec::new()
1091 } else {
1092 path.split('/').collect()
1093 };
1094 let max = if is_dir {
1095 parts.len()
1096 } else {
1097 parts.len().saturating_sub(1)
1098 };
1099 for item in parts.iter().take(max) {
1100 out.push(*item);
1101 }
1102 out
1103}
1104
1105fn pattern_matches_kind(
1106 kind: &IgnorePatternKind,
1107 pattern: &str,
1108 text: &str,
1109 pathname: bool,
1110 ignore_case: bool,
1111) -> bool {
1112 match kind {
1113 IgnorePatternKind::Literal(lit) => eq_fold(text, lit, ignore_case),
1114 IgnorePatternKind::Prefix(prefix) => starts_with_fold(text, prefix, ignore_case),
1115 IgnorePatternKind::Suffix(suffix) => ends_with_fold(text, suffix, ignore_case),
1116 IgnorePatternKind::Wild => {
1117 let mut flags = if pathname { WM_PATHNAME } else { 0 };
1118 if ignore_case {
1119 flags |= WM_CASEFOLD;
1120 }
1121 wildmatch(pattern.as_bytes(), text.as_bytes(), flags)
1122 }
1123 }
1124}
1125
1126fn rule_body_matches(rule: &IgnoreRule, text: &str, pathname: bool, ignore_case: bool) -> bool {
1127 pattern_matches_kind(&rule.match_kind, &rule.body, text, pathname, ignore_case)
1128}
1129
1130#[derive(Debug, Clone)]
1132struct SparsePattern {
1133 negative: bool,
1134 directory_only: bool,
1135 anchored: bool,
1136 has_slash: bool,
1137 body: String,
1138}
1139
1140impl SparsePattern {
1141 fn from_line(line: &str) -> Option<Self> {
1142 let mut raw_line = line.trim_end_matches('\r').to_owned();
1143 trim_trailing_spaces_git(&mut raw_line);
1144 if raw_line.is_empty() || raw_line.starts_with('#') {
1145 return None;
1146 }
1147
1148 let mut raw = raw_line;
1149
1150 let mut negative = false;
1151 if let Some(rest) = raw.strip_prefix('!') {
1152 negative = true;
1153 raw = rest.to_owned();
1154 }
1155 if raw.is_empty() {
1156 return None;
1157 }
1158
1159 let mut directory_only = false;
1162 if raw.len() > 1 && raw.ends_with('/') {
1163 directory_only = true;
1164 raw.pop();
1165 }
1166 if raw.is_empty() {
1167 return None;
1168 }
1169
1170 let mut anchored = false;
1171 if let Some(rest) = raw.strip_prefix('/') {
1172 anchored = true;
1173 raw = rest.to_owned();
1174 }
1175 if raw.is_empty() && anchored && directory_only {
1178 raw = "*".to_owned();
1179 directory_only = false;
1180 } else if raw.is_empty() {
1181 return None;
1182 }
1183
1184 let has_slash = raw.contains('/');
1185 Some(Self {
1186 negative,
1187 directory_only,
1188 anchored,
1189 has_slash,
1190 body: raw,
1191 })
1192 }
1193}
1194
1195fn sparse_pattern_matches(p: &SparsePattern, pathname: &str, as_directory: bool) -> bool {
1201 if p.directory_only && !as_directory {
1207 return false;
1208 }
1209 if p.directory_only && p.anchored && !p.has_slash && p.body == "*" {
1213 let trimmed = pathname.trim_end_matches('/');
1214 return trimmed.contains('/') || as_directory;
1215 }
1216 if !p.has_slash && !p.anchored {
1217 let pat = p.body.as_bytes();
1218 sparse_unanchored_basename_matches(&p.body, pathname)
1219 || wildmatch(pat, pathname.as_bytes(), WM_PATHNAME)
1220 || pathname
1221 .split('/')
1222 .any(|comp| wildmatch(pat, comp.as_bytes(), 0))
1223 } else if p.anchored && !p.has_slash && p.body == "*" && !p.directory_only {
1224 !pathname.contains('/')
1228 } else {
1229 wildmatch(p.body.as_bytes(), pathname.as_bytes(), WM_PATHNAME)
1230 }
1231}
1232
1233fn sparse_pattern_matches_path(p: &SparsePattern, pathname: &str) -> bool {
1234 sparse_pattern_matches(p, pathname, false)
1235}
1236
1237#[must_use]
1239pub fn parse_sparse_patterns_from_blob(content: &str) -> Vec<String> {
1240 let mut patterns = Vec::new();
1241 for line in content.lines() {
1242 let t = line.trim_end_matches('\r');
1243 if t.is_empty() || t.starts_with('#') {
1244 continue;
1245 }
1246 patterns.push(t.to_owned());
1247 }
1248 patterns
1249}
1250
1251fn sparse_list_last_match(
1252 pathname: &str,
1253 as_directory: bool,
1254 parsed: &[SparsePattern],
1255) -> Option<bool> {
1256 for p in parsed.iter().rev() {
1257 if sparse_pattern_matches(p, pathname, as_directory) {
1258 return Some(!p.negative);
1259 }
1260 }
1261 None
1262}
1263
1264#[must_use]
1270pub fn path_in_sparse_checkout(path: &str, lines: &[String], work_tree: Option<&Path>) -> bool {
1271 ParsedSparsePatterns::parse(lines).includes(path, work_tree)
1272}
1273
1274#[derive(Debug, Clone)]
1276pub(crate) struct ParsedSparsePatterns(Vec<SparsePattern>);
1277
1278impl ParsedSparsePatterns {
1279 pub(crate) fn parse(lines: &[String]) -> Self {
1281 Self(
1282 lines
1283 .iter()
1284 .filter_map(|l| SparsePattern::from_line(l))
1285 .collect(),
1286 )
1287 }
1288
1289 pub(crate) fn includes(&self, path: &str, work_tree: Option<&Path>) -> bool {
1291 if path.is_empty() || self.0.is_empty() {
1292 return true;
1293 }
1294
1295 let mut end = path.len();
1296 let mut as_directory = false;
1297
1298 loop {
1299 let pathname = &path[..end];
1300 let dtype_is_dir =
1301 work_tree.is_some_and(|wt| wt.join(pathname).is_dir()) || as_directory;
1302
1303 match sparse_list_last_match(pathname, dtype_is_dir, &self.0) {
1304 Some(true) => return true,
1305 Some(false) => return false,
1306 None => {
1307 let Some(slash) = path[..end].rfind('/') else {
1308 return false;
1311 };
1312 end = slash;
1313 as_directory = true;
1314 }
1315 }
1316 }
1317 }
1318}
1319
1320#[must_use]
1328pub fn path_matches_sparse_pattern_list(pathname: &str, lines: &[String]) -> Option<bool> {
1329 let parsed: Vec<SparsePattern> = lines
1330 .iter()
1331 .filter_map(|l| SparsePattern::from_line(l))
1332 .collect();
1333 if parsed.is_empty() {
1334 return None;
1335 }
1336 for p in parsed.iter().rev() {
1337 if sparse_pattern_matches_path(p, pathname) {
1338 return Some(!p.negative);
1339 }
1340 }
1341 None
1342}
1343
1344fn sparse_unanchored_basename_matches(pat: &str, path: &str) -> bool {
1345 let basename = path.rsplit('/').next().unwrap_or(path);
1346 if basename == pat {
1347 return true;
1348 }
1349 if let Some(rest) = basename.strip_prefix(pat) {
1350 return rest.starts_with('.') || rest.starts_with('/');
1351 }
1352 if path == pat {
1353 return true;
1354 }
1355 path.starts_with(&format!("{pat}/"))
1356}
1357
1358#[must_use]
1360pub fn submodule_containing_path(repo_rel_path: &str, index: &Index) -> Option<String> {
1361 let bytes = repo_rel_path.as_bytes();
1362 if bytes.is_empty() {
1363 return None;
1364 }
1365 let mut best: Option<String> = None;
1366 let mut end = 0usize;
1367 while end < bytes.len() {
1368 let Some(slash) = bytes[end..].iter().position(|&b| b == b'/') else {
1369 break;
1370 };
1371 end += slash;
1372 if let Some(entry) = index.get(&bytes[..end], 0) {
1373 if entry.mode == MODE_GITLINK {
1374 best = Some(String::from_utf8_lossy(&bytes[..end]).into_owned());
1375 }
1376 }
1377 end += 1;
1378 }
1379 best
1380}
1381
1382pub fn normalize_repo_relative(repo: &Repository, cwd: &Path, path: &str) -> Result<String> {
1394 let Some(work_tree) = &repo.work_tree() else {
1395 return Err(Error::PathError(
1396 "this operation must be run in a work tree".to_owned(),
1397 ));
1398 };
1399 if path.starts_with(':') {
1400 return Ok(path.to_owned());
1401 }
1402 let query_as_dir = path.ends_with('/');
1403 let input = Path::new(path);
1404 let combined = if input.is_absolute() {
1405 input.to_path_buf()
1406 } else {
1407 cwd.join(input)
1408 };
1409 let normalized = normalize_path(&combined);
1410 let mut rel = match normalized.strip_prefix(work_tree) {
1414 Ok(relative) => crate::git_path::path_to_git_slash(relative),
1415 Err(_) => {
1416 crate::git_path::strip_worktree_prefix(&normalized, work_tree).ok_or_else(|| {
1417 Error::PathError(format!("path '{path}' is outside repository work tree"))
1418 })?
1419 }
1420 };
1421 if query_as_dir && !rel.is_empty() && !rel.ends_with('/') {
1422 rel.push('/');
1423 }
1424 Ok(rel)
1425}
1426
1427fn normalize_path(path: &Path) -> PathBuf {
1428 let mut out = PathBuf::new();
1429 for component in path.components() {
1430 match component {
1431 Component::CurDir => {}
1432 Component::ParentDir => {
1433 out.pop();
1434 }
1435 other => out.push(other.as_os_str()),
1436 }
1437 }
1438 out
1439}
1440
1441#[cfg(test)]
1442mod normalization_tests {
1443 use super::*;
1444
1445 #[test]
1446 fn bare_repository_queries_return_a_path_error() {
1447 let dir = tempfile::tempdir().unwrap();
1448 let repo = crate::repo::init_repository(
1449 dir.path(),
1450 true,
1451 "main",
1452 None,
1453 crate::RefStorageFormat::Files,
1454 )
1455 .unwrap();
1456 assert!(matches!(
1457 normalize_repo_relative(&repo, dir.path(), "missing/file"),
1458 Err(Error::PathError(_))
1459 ));
1460 }
1461}
1462
1463#[cfg(test)]
1464mod sparse_checkout_tests {
1465 use super::*;
1466
1467 #[test]
1468 fn non_cone_default_init_patterns() {
1469 let lines = vec!["/*".into(), "!/*/".into()];
1470 assert!(path_in_sparse_checkout("a", &lines, None));
1471 assert!(!path_in_sparse_checkout("folder1/a", &lines, None));
1472 let wt = std::env::temp_dir().join("grit-sparse-wt-test");
1473 let _ = std::fs::create_dir_all(wt.join("folder1"));
1474 let _ = std::fs::write(wt.join("a"), b"x");
1475 assert!(!path_in_sparse_checkout("folder1/a", &lines, Some(&wt)));
1476 assert!(!path_in_sparse_checkout("folder1", &lines, Some(&wt)));
1477 }
1478
1479 #[test]
1480 fn unanchored_glob_matches_path_components() {
1481 let lines = vec!["/*".into(), "!/*/".into(), "*folder*".into()];
1482 assert!(path_in_sparse_checkout("folder1/a", &lines, None));
1483 assert!(path_in_sparse_checkout("folder2/a", &lines, None));
1484 assert!(!path_in_sparse_checkout("deep/a", &lines, None));
1485 }
1486}
1487
1488#[cfg(test)]
1489mod gitignore_glob_tests {
1490 use super::*;
1491
1492 fn pathname_pattern_matches(pattern: &str, text: &str) -> bool {
1493 pattern_matches_kind(
1494 &classify_ignore_pattern(pattern),
1495 pattern,
1496 text,
1497 true,
1498 false,
1499 )
1500 }
1501
1502 fn rule(line: &str, base_dir: &str) -> IgnoreRule {
1503 parse_rule_line(line, 1, IgnoreSource::InfoExclude, base_dir).unwrap()
1504 }
1505
1506 #[test]
1507 fn every_pattern_kind_folds_ascii_case_only_with_ignore_case() {
1508 for (pattern, kind, path) in [
1509 ("README", "literal", "readme"),
1510 ("Target*", "prefix", "target-debug"),
1511 ("*.LOG", "suffix", "debug.log"),
1512 ("[Dd]EBUG?.txt", "wild", "debug1.TXT"),
1513 ] {
1514 let r = rule(pattern, "");
1515 assert!(!rule_matches(&r, path, false, false), "{kind}: {pattern}");
1516 assert!(rule_matches(&r, path, false, true), "{kind}: {pattern}");
1517 assert!(rule_matches(&r, &format!("sub/{path}"), false, true));
1518 }
1519 assert!(!rule_matches(&rule("*.LOG", ""), "x.lag", false, true));
1520 assert!(!rule_matches(&rule("Target*", ""), "tar", false, true));
1521 assert!(!rule_matches(&rule("*.LOG", ""), "LOG", false, true));
1522 assert!(!rule_matches(&rule("*É", ""), "é", false, true));
1524 assert!(!rule_matches(&rule("Über", ""), "über", false, true));
1525 assert!(rule_matches(&rule("Über", ""), "ÜBER", false, true));
1526 }
1527
1528 #[test]
1529 fn directory_and_anchored_rules_fold_case() {
1530 let dir_only = rule("Build/", "");
1531 assert!(rule_matches(&dir_only, "build", true, true));
1532 assert!(rule_matches(&dir_only, "src/BUILD", true, true));
1533 assert!(!rule_matches(&dir_only, "build", true, false));
1534 assert!(!rule_matches(&dir_only, "build", false, true));
1535 let anchored = rule("/Docs/*.PDF", "");
1536 assert!(rule_matches(&anchored, "docs/manual.pdf", false, true));
1537 assert!(!rule_matches(&anchored, "docs/sub/manual.pdf", false, true));
1538 assert!(!rule_matches(&anchored, "docs/manual.pdf", false, false));
1539 }
1540
1541 #[test]
1542 fn nested_gitignore_base_dir_folds_case() {
1543 let nested = rule("*.tmp", "Sub/Dir");
1544 assert!(rule_matches(&nested, "Sub/Dir/a.tmp", false, false));
1545 assert!(!rule_matches(&nested, "sub/dir/a.tmp", false, false));
1546 assert!(rule_matches(&nested, "sub/dir/a.tmp", false, true));
1547 assert!(!rule_matches(&nested, "sub/dirx/a.tmp", false, true));
1548 assert!(!rule_matches(&nested, "sub/di", false, true));
1549 assert_eq!(strip_base("Sub", "sub", true), Some(""));
1550 assert_eq!(strip_base("Sub", "SUB/x", true), Some("x"));
1551 assert_eq!(strip_base("Sub", "subway/x", true), None);
1552 assert_eq!(strip_base("Sub", "sub/x", false), None);
1553 }
1554
1555 #[test]
1556 fn precompiled_literal_prefix_suffix_patterns() {
1557 assert!(matches!(
1558 classify_ignore_pattern("build"),
1559 IgnorePatternKind::Literal(_)
1560 ));
1561 assert!(matches!(
1562 classify_ignore_pattern("target*"),
1563 IgnorePatternKind::Prefix(_)
1564 ));
1565 assert!(matches!(
1566 classify_ignore_pattern("*.o"),
1567 IgnorePatternKind::Suffix(_)
1568 ));
1569 assert!(matches!(
1570 classify_ignore_pattern("a*b"),
1571 IgnorePatternKind::Wild
1572 ));
1573 assert!(matches!(
1574 classify_ignore_pattern(r"foo\ bar"),
1575 IgnorePatternKind::Wild
1576 ));
1577 assert!(matches!(
1578 classify_ignore_pattern("foo/*"),
1579 IgnorePatternKind::Wild
1580 ));
1581 assert!(!pathname_pattern_matches("foo/*", "foo/bar/baz"));
1582 assert!(rule_body_matches(
1583 &IgnoreRule {
1584 source: IgnoreSource::InfoExclude,
1585 line_number: 1,
1586 pattern_text: "*.log".into(),
1587 negative: false,
1588 directory_only: false,
1589 anchored: false,
1590 has_slash: false,
1591 body: "*.log".into(),
1592 base_dir: String::new(),
1593 match_kind: IgnorePatternKind::Suffix(".log".into()),
1594 },
1595 "app.log",
1596 false,
1597 false
1598 ));
1599 }
1600
1601 #[test]
1602 fn dir_star_extension_matches_nested_path() {
1603 assert!(!pathname_pattern_matches("doc/*.pdf", "doc/sub/manual.pdf"));
1605 assert!(pathname_pattern_matches("doc/*.pdf", "doc/manual.pdf"));
1606 assert!(!pathname_pattern_matches("doc/*.pdf", "other/manual.pdf"));
1607 assert!(pathname_pattern_matches(
1608 "doc/**/*.pdf",
1609 "doc/sub/manual.pdf"
1610 ));
1611 }
1612
1613 #[test]
1614 fn dir_star_extension_unexpanded_when_parent_has_glob() {
1615 assert!(!pathname_pattern_matches("*/foo/*.pdf", "a/foo/sub/x.pdf"));
1616 }
1617
1618 #[test]
1619 fn nested_dir_star_extension() {
1620 assert!(!pathname_pattern_matches("foo/bar/*.c", "foo/bar/baz/x.c"));
1621 }
1622}
1623
1624#[cfg(all(test, unix))]
1625mod per_directory_gitignore_tests {
1626 use super::*;
1627 use crate::repo::init_repository;
1628 use crate::RefStorageFormat;
1629 use std::os::unix::ffi::OsStringExt;
1630 use std::os::unix::fs::symlink;
1631 use tempfile::TempDir;
1632
1633 fn ignored_with_warnings(repo: &Repository, path: &str) -> (bool, Vec<String>) {
1634 let mut matcher = IgnoreMatcher::from_repository(repo).unwrap();
1635 let ignored = matcher.is_path_ignored(repo, None, path, false).unwrap();
1636 (ignored, matcher.take_warnings())
1637 }
1638
1639 #[test]
1640 fn symlinked_gitignore_is_skipped_with_warning_and_plain_one_applies() {
1641 let dir = TempDir::new().unwrap();
1642 let root = dir.path();
1643 let repo = init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
1644 fs::create_dir_all(root.join("plain")).unwrap();
1645 fs::create_dir_all(root.join("to_file")).unwrap();
1646 fs::create_dir_all(root.join("to_dir")).unwrap();
1647 fs::create_dir_all(root.join("none")).unwrap();
1648 fs::write(root.join("plain/.gitignore"), "*.log\n").unwrap();
1649 fs::write(root.join("rules"), "*.log\n").unwrap();
1650 symlink(root.join("rules"), root.join("to_file/.gitignore")).unwrap();
1651 symlink(root.join("plain"), root.join("to_dir/.gitignore")).unwrap();
1652 fs::create_dir_all(root.join("dangling")).unwrap();
1653 symlink(root.join("no-such-rules"), root.join("dangling/.gitignore")).unwrap();
1654 fs::create_dir_all(root.join("to_fifo")).unwrap();
1655 let fifo = std::ffi::CString::new(root.join("fifo").into_os_string().into_vec()).unwrap();
1656 assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o644) }, 0);
1658 symlink(root.join("fifo"), root.join("to_fifo/.gitignore")).unwrap();
1659
1660 assert_eq!(ignored_with_warnings(&repo, "plain/a.log"), (true, vec![]));
1661 assert_eq!(ignored_with_warnings(&repo, "none/a.log"), (false, vec![]));
1662 let (done_tx, done_rx) = std::sync::mpsc::channel();
1663 let fifo_root = root.to_path_buf();
1664 std::thread::spawn(move || {
1665 let options = crate::environment::RepositoryOptions::empty().with_work_tree(&fifo_root);
1666 let repo = Repository::open(&fifo_root.join(".git"), &options).unwrap();
1667 let _ = done_tx.send(ignored_with_warnings(&repo, "to_fifo/a.log"));
1668 });
1669 let (ignored, warnings) = done_rx
1670 .recv_timeout(std::time::Duration::from_secs(5))
1671 .expect("a .gitignore symlink to a FIFO must be rejected without opening it");
1672 assert!(!ignored);
1673 assert_eq!(warnings.len(), 1, "{warnings:?}");
1674 for dir in ["to_file", "to_dir", "dangling"] {
1675 let (ignored, warnings) = ignored_with_warnings(&repo, &format!("{dir}/a.log"));
1676 assert!(!ignored, "{dir}: symlinked .gitignore must not apply");
1677 assert_eq!(warnings.len(), 1, "{dir}: {warnings:?}");
1678 assert!(warnings[0].contains(&format!("{dir}/.gitignore")));
1679 }
1680 }
1681}
1682
1683#[cfg(test)]
1684mod ignore_case_matcher_tests {
1685 use super::*;
1686 use crate::repo::init_repository;
1687 use crate::RefStorageFormat;
1688 use tempfile::TempDir;
1689
1690 fn issue_repo(ignore_case: bool) -> (TempDir, Repository) {
1691 let dir = TempDir::new().unwrap();
1692 let root = dir.path();
1693 init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
1694 let mut config = fs::read_to_string(root.join(".git/config")).unwrap();
1695 config.push_str(&format!("[core]\n\tignorecase = {ignore_case}\n"));
1696 fs::write(root.join(".git/config"), config).unwrap();
1697 fs::write(root.join(".gitignore"), "Build/\n*.LOG\n!keep.log\n").unwrap();
1698 fs::create_dir_all(root.join("build")).unwrap();
1699 for file in ["build/x.o", "debug.log", "keep.log", "README"] {
1700 fs::write(root.join(file), "x\n").unwrap();
1701 }
1702 let options = crate::environment::RepositoryOptions::empty().with_work_tree(root);
1703 let repo = Repository::open(&root.join(".git"), &options).unwrap();
1704 (dir, repo)
1705 }
1706
1707 fn ignored(matcher: &mut IgnoreMatcher, repo: &Repository, path: &str, is_dir: bool) -> bool {
1708 let fast = matcher.is_path_ignored(repo, None, path, is_dir).unwrap();
1709 let (verbose, _) = matcher.check_path(repo, None, path, is_dir).unwrap();
1710 assert_eq!(fast, verbose, "{path}");
1711 fast
1712 }
1713
1714 #[test]
1715 fn from_repository_honors_core_ignorecase() {
1716 let (_dir, repo) = issue_repo(true);
1717 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
1718 assert!(matcher.ignore_case());
1719 assert!(ignored(&mut matcher, &repo, "build", true));
1720 assert!(ignored(&mut matcher, &repo, "build/x.o", false));
1721 assert!(ignored(&mut matcher, &repo, "debug.log", false));
1722 assert!(!ignored(&mut matcher, &repo, "keep.log", false));
1723 assert!(!ignored(&mut matcher, &repo, "KEEP.LOG", false));
1724 assert!(!ignored(&mut matcher, &repo, "README", false));
1725 let (_, matched) = matcher.check_path(&repo, None, "build/x.o", false).unwrap();
1726 assert_eq!(matched.unwrap().pattern_text, "Build/");
1727 }
1728
1729 #[test]
1730 fn case_sensitive_by_default_and_overridable() {
1731 let (_dir, repo) = issue_repo(false);
1732 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
1733 assert!(!matcher.ignore_case());
1734 assert!(!ignored(&mut matcher, &repo, "build/x.o", false));
1735 assert!(!ignored(&mut matcher, &repo, "debug.log", false));
1736 assert!(ignored(&mut matcher, &repo, "Build/x.o", false));
1737
1738 let mut folded = matcher.with_ignore_case(true);
1739 assert!(ignored(&mut folded, &repo, "build/x.o", false));
1740 assert!(ignored(&mut folded, &repo, "debug.log", false));
1741 assert!(!IgnoreMatcher::default().ignore_case());
1742 }
1743}
1744
1745#[cfg(test)]
1746mod index_lookup_scaling_tests {
1747 use super::*;
1748 use crate::index::{index_path_cmp, Index, IndexEntry, MODE_REGULAR};
1749 use crate::objects::ObjectId;
1750 use crate::repo::init_repository;
1751 use crate::RefStorageFormat;
1752 use tempfile::TempDir;
1753
1754 fn dummy_oid() -> ObjectId {
1755 ObjectId::from_bytes(&[0u8; 20]).unwrap()
1756 }
1757
1758 fn make_entry(path: &str) -> IndexEntry {
1759 IndexEntry {
1760 ctime_sec: 0,
1761 ctime_nsec: 0,
1762 mtime_sec: 0,
1763 mtime_nsec: 0,
1764 dev: 0,
1765 ino: 0,
1766 mode: MODE_REGULAR,
1767 uid: 0,
1768 gid: 0,
1769 size: 0,
1770 oid: dummy_oid(),
1771 flags: path.len().min(0xFFF) as u16,
1772 flags_extended: None,
1773 path: path.as_bytes().to_vec(),
1774 base_index_pos: 0,
1775 }
1776 }
1777
1778 #[test]
1779 fn tracked_check_path_index_lookups_sublinear_in_index_size() {
1780 const N: usize = 100_000;
1781 const M: usize = 1_000;
1782 let dir = TempDir::new().unwrap();
1783 let repo =
1784 init_repository(dir.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1785 let mut index = Index::new();
1786 for i in 0..N {
1787 index.add_or_replace(make_entry(&format!("tracked/{i:06}.txt")));
1788 }
1789 let mut matcher = IgnoreMatcher::default();
1790 index_path_cmp::reset();
1791 for i in 0..M {
1792 let path = format!("untracked/{i:06}.txt");
1793 let (ignored, _) = matcher
1794 .check_path(&repo, Some(&index), &path, false)
1795 .unwrap();
1796 assert!(!ignored, "untracked path must not be ignored");
1797 }
1798 let cmps = index_path_cmp::take();
1799 let log_n = (N as f64).log2().ceil() as usize;
1800 let bound = 100 * M * log_n;
1801 assert!(
1802 cmps < bound,
1803 "index path comparisons {cmps} should stay under {bound} (N={N}, M={M})"
1804 );
1805 }
1806}