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, Clone)]
192struct DirRuleSet {
193 base_dir: String,
194 rules: Vec<IgnoreRule>,
195}
196
197#[derive(Debug, Clone)]
199struct DirState {
200 stack: Vec<usize>,
202 extendable: bool,
205 excluded: Option<IgnoreMatch>,
208}
209
210impl From<&IgnoreRule> for IgnoreMatch {
211 fn from(rule: &IgnoreRule) -> Self {
212 Self {
213 source: rule.source.clone(),
214 line_number: rule.line_number,
215 pattern_text: rule.pattern_text.clone(),
216 negative: rule.negative,
217 }
218 }
219}
220
221#[derive(Debug, Default, Clone)]
223pub struct IgnoreMatcher {
224 cli_rules: Vec<IgnoreRule>,
226 global_rules: Vec<IgnoreRule>,
227 info_rules: Vec<IgnoreRule>,
228 exclude_from_rules: Vec<IgnoreRule>,
230 gitignore_cache: HashMap<String, usize>,
232 rule_sets: Vec<DirRuleSet>,
233 per_directory_name: Option<String>,
235 read_per_directory: bool,
240 pub warnings: Vec<String>,
242 dir_state_ids: HashMap<String, usize>,
245 dir_states: Vec<DirState>,
246 last_dir: Option<(String, usize)>,
248 ignore_case: bool,
250 known_worktree_directories: std::collections::HashSet<String>,
252}
253
254impl IgnoreMatcher {
255 pub fn from_repository(repo: &Repository) -> Result<Self> {
269 let ignore_case = crate::crlf::config_ignorecase(repo.config()?.as_ref());
270 Ok(Self {
271 global_rules: load_global_excludes(repo)?,
272 info_rules: load_info_excludes(repo)?,
273 read_per_directory: true,
275 ignore_case,
276 ..Self::default()
277 })
278 }
279
280 #[must_use]
294 pub fn with_ignore_case(mut self, ignore_case: bool) -> Self {
295 self.ignore_case = ignore_case;
296 self.clear_dir_states();
297 self
298 }
299
300 fn clear_dir_states(&mut self) {
303 self.dir_state_ids.clear();
304 self.dir_states.clear();
305 self.last_dir = None;
306 }
307
308 #[must_use]
310 pub fn ignore_case(&self) -> bool {
311 self.ignore_case
312 }
313
314 pub fn add_exclude_from_files(&mut self, paths: &[PathBuf], cwd: &Path) -> Result<()> {
318 for path in paths {
319 let resolved = if path.is_absolute() {
320 path.clone()
321 } else {
322 cwd.join(path)
323 };
324 let display = path.display().to_string();
325 let mut more = load_rules_from_file(
326 &resolved,
327 IgnoreSource::CoreExcludesFile(display),
328 String::new(),
329 false,
330 &mut self.warnings,
331 )?;
332 self.exclude_from_rules.append(&mut more);
333 }
334 self.clear_dir_states();
335 Ok(())
336 }
337
338 pub fn set_per_directory_name(&mut self, name: &str) {
342 self.per_directory_name = Some(name.to_owned());
343 self.read_per_directory = true;
344 self.gitignore_cache.clear();
346 self.rule_sets.clear();
347 self.clear_dir_states();
348 }
349
350 pub fn add_cli_excludes(&mut self, patterns: &[String]) {
352 for pat in patterns {
353 if let Some(rule) = parse_rule_line(
354 pat,
355 1,
356 IgnoreSource::PerDirectory("--exclude option".into()),
357 "",
358 ) {
359 self.cli_rules.push(rule);
360 }
361 }
362 self.clear_dir_states();
363 }
364
365 #[must_use]
367 pub fn take_warnings(&mut self) -> Vec<String> {
368 std::mem::take(&mut self.warnings)
369 }
370
371 pub fn is_path_ignored(
376 &mut self,
377 repo: &Repository,
378 index: Option<&Index>,
379 repo_rel_path: &str,
380 is_dir: bool,
381 ) -> Result<bool> {
382 if is_tracked(index, repo_rel_path) {
383 return Ok(false);
384 }
385 let state = self.dir_state_id(repo, index, parent_dir(repo_rel_path), false)?;
386 let state = &self.dir_states[state];
387 if state.excluded.is_some() {
388 return Ok(true);
389 }
390 Ok(self
391 .last_matching_rule(repo_rel_path, is_dir, &state.stack)
392 .is_some_and(|rule| !rule.negative))
393 }
394
395 pub fn check_path(
415 &mut self,
416 repo: &Repository,
417 index: Option<&Index>,
418 repo_rel_path: &str,
419 is_dir: bool,
420 ) -> Result<(bool, Option<IgnoreMatch>)> {
421 self.check_path_with_ancestors(repo, index, repo_rel_path, is_dir, false)
422 }
423
424 fn check_path_with_ancestors(
428 &mut self,
429 repo: &Repository,
430 index: Option<&Index>,
431 repo_rel_path: &str,
432 is_dir: bool,
433 ancestors_are_dirs: bool,
434 ) -> Result<(bool, Option<IgnoreMatch>)> {
435 if is_tracked(index, repo_rel_path) {
436 return Ok((false, None));
437 }
438 let parent = parent_dir(repo_rel_path);
439 let state = self.dir_state_id(repo, index, parent, ancestors_are_dirs)?;
440 let state = &self.dir_states[state];
441 if let Some(ancestor_match) = &state.excluded {
444 return Ok((true, Some(ancestor_match.clone())));
445 }
446 let Some(rule) = self.last_matching_rule(repo_rel_path, is_dir, &state.stack) else {
447 return Ok((false, None));
448 };
449 if rule.negative {
450 return Ok((false, Some(rule.into())));
451 }
452 let attributed = self
453 .rules_in_priority_order(&state.stack)
454 .filter(|candidate| candidate.directory_only)
455 .filter(|candidate| rule_matches(candidate, repo_rel_path, is_dir, self.ignore_case))
456 .reduce(|best, candidate| {
457 let key = |r: &IgnoreRule| (base_dir_depth(&r.base_dir), r.line_number);
458 if key(candidate) < key(best) {
459 candidate
460 } else {
461 best
462 }
463 })
464 .unwrap_or(rule);
465 Ok((true, Some(attributed.into())))
466 }
467
468 fn last_matching_rule(&self, path: &str, is_dir: bool, stack: &[usize]) -> Option<&IgnoreRule> {
476 let fold = self.ignore_case;
477 last_match_in("", &self.cli_rules, path, is_dir, fold)
478 .or_else(|| {
479 stack.iter().rev().find_map(|&id| {
480 let set = self.rule_sets.get(id)?;
481 last_match_in(&set.base_dir, &set.rules, path, is_dir, fold)
482 })
483 })
484 .or_else(|| last_match_in("", &self.info_rules, path, is_dir, fold))
485 .or_else(|| last_match_in("", &self.global_rules, path, is_dir, fold))
486 .or_else(|| last_match_in("", &self.exclude_from_rules, path, is_dir, fold))
487 }
488
489 fn rules_in_priority_order<'a>(
492 &'a self,
493 stack: &'a [usize],
494 ) -> impl Iterator<Item = &'a IgnoreRule> + 'a {
495 let per_dir = stack
496 .iter()
497 .rev()
498 .filter_map(|&id| self.rule_sets.get(id))
499 .flat_map(|set| set.rules.iter());
500 self.cli_rules
501 .iter()
502 .chain(per_dir)
503 .chain(self.exclude_from_rules.iter())
504 .chain(self.global_rules.iter())
505 .chain(self.info_rules.iter())
506 }
507
508 pub fn note_directory_entered(
512 &mut self,
513 repo: &Repository,
514 index: Option<&Index>,
515 dir_rel: &str,
516 ) -> Result<()> {
517 if !self.read_per_directory {
518 return Ok(());
519 }
520 if self.gitignore_cache.contains_key(dir_rel) {
521 return Ok(());
522 }
523 self.known_worktree_directories.insert(dir_rel.to_owned());
524 self.rule_set_id(repo, index, dir_rel)?;
525 Ok(())
526 }
527
528 pub(crate) fn note_directory_known(&mut self, dir_rel: &str) {
535 if self.read_per_directory
536 && !self.gitignore_cache.contains_key(dir_rel)
537 && !self.known_worktree_directories.contains(dir_rel)
538 {
539 self.known_worktree_directories.insert(dir_rel.to_owned());
540 }
541 }
542
543 fn rule_set_id(
545 &mut self,
546 repo: &Repository,
547 index: Option<&Index>,
548 dir: &str,
549 ) -> Result<usize> {
550 if let Some(&id) = self.gitignore_cache.get(dir) {
551 return Ok(id);
552 }
553 let per_dir_name = self.per_directory_name.as_deref();
554 let rules = load_gitignore_for_dir(repo, index, dir, per_dir_name, &mut self.warnings)?;
555 let id = self.rule_sets.len();
556 self.rule_sets.push(DirRuleSet {
557 base_dir: dir.to_owned(),
558 rules,
559 });
560 self.gitignore_cache.insert(dir.to_owned(), id);
561 Ok(id)
562 }
563
564 fn dir_state_id(
570 &mut self,
571 repo: &Repository,
572 index: Option<&Index>,
573 dir: &str,
574 ancestors_are_dirs: bool,
575 ) -> Result<usize> {
576 if let Some((last, id)) = &self.last_dir {
577 if last == dir {
578 return Ok(*id);
579 }
580 }
581 let id = match self.dir_state_ids.get(dir) {
582 Some(&id) => id,
583 None => self.build_dir_state(repo, index, dir, ancestors_are_dirs)?,
584 };
585 match &mut self.last_dir {
586 Some((last, last_id)) => {
587 last.clear();
588 last.push_str(dir);
589 *last_id = id;
590 }
591 None => self.last_dir = Some((dir.to_owned(), id)),
592 }
593 Ok(id)
594 }
595
596 fn build_dir_state(
600 &mut self,
601 repo: &Repository,
602 index: Option<&Index>,
603 dir: &str,
604 ancestors_are_dirs: bool,
605 ) -> Result<usize> {
606 let state = if dir.is_empty() {
607 let stack = if self.read_per_directory {
608 vec![self.rule_set_id(repo, index, "")?]
609 } else {
610 Vec::new()
611 };
612 DirState {
613 stack,
614 extendable: self.read_per_directory,
615 excluded: None,
616 }
617 } else {
618 let parent = parent_dir(dir);
619 let parent_id = match self.dir_state_ids.get(parent) {
620 Some(&id) => id,
621 None => self.build_dir_state(repo, index, parent, ancestors_are_dirs)?,
622 };
623 let parent_state = &self.dir_states[parent_id];
624 let excluded = parent_state.excluded.clone().or_else(|| {
625 self.last_matching_rule(dir, true, &parent_state.stack)
626 .filter(|rule| !rule.negative)
627 .map(IgnoreMatch::from)
628 });
629 let mut stack = parent_state.stack.clone();
630 let mut extendable = parent_state.extendable;
631 if extendable
634 && (self.known_worktree_directories.contains(dir)
635 || ancestors_are_dirs
636 || !worktree_path_is_existing_file(repo, dir))
637 {
638 stack.push(self.rule_set_id(repo, index, dir)?);
639 } else {
640 extendable = false;
641 }
642 DirState {
643 stack,
644 extendable,
645 excluded,
646 }
647 };
648 let id = self.dir_states.len();
649 self.dir_states.push(state);
650 self.dir_state_ids.insert(dir.to_owned(), id);
651 Ok(id)
652 }
653}
654
655fn load_global_excludes(repo: &Repository) -> Result<Vec<IgnoreRule>> {
656 let config = repo.config()?;
657 let env = repo.environment();
658 let (resolved, display_path) = if let Some(raw_path) = config.get("core.excludesfile") {
659 let expanded = parse_path(env, &raw_path);
660 let resolved = if Path::new(&expanded).is_absolute() {
661 PathBuf::from(&expanded)
662 } else if let Some(work_tree) = &repo.work_tree() {
663 work_tree.join(&expanded)
664 } else {
665 repo.git_dir().join(&expanded)
666 };
667 (resolved, raw_path)
668 } else if let Some(path) = env.default_xdg_config_git_file("ignore") {
669 let display = path.display().to_string();
670 (path, display)
671 } else {
672 return Ok(Vec::new());
673 };
674
675 let mut sink = Vec::new();
676 load_rules_from_file(
677 &resolved,
678 IgnoreSource::CoreExcludesFile(display_path),
679 String::new(),
680 false,
681 &mut sink,
682 )
683}
684
685fn load_info_excludes(repo: &Repository) -> Result<Vec<IgnoreRule>> {
686 let path = repo.git_dir().join("info/exclude");
687 let mut sink = Vec::new();
688 load_rules_from_file(
689 &path,
690 IgnoreSource::InfoExclude,
691 String::new(),
692 false,
693 &mut sink,
694 )
695}
696
697fn load_gitignore_for_dir(
698 repo: &Repository,
699 index: Option<&Index>,
700 dir: &str,
701 per_dir_name: Option<&str>,
702 warnings: &mut Vec<String>,
703) -> Result<Vec<IgnoreRule>> {
704 let Some(work_tree) = &repo.work_tree() else {
705 return Ok(Vec::new());
706 };
707 let file_name = per_dir_name.unwrap_or(".gitignore");
708 let path = if dir.is_empty() {
709 work_tree.join(file_name)
710 } else {
711 work_tree.join(dir).join(file_name)
712 };
713 let source_display = if dir.is_empty() {
714 file_name.to_owned()
715 } else {
716 format!("{dir}/{file_name}")
717 };
718 let source = IgnoreSource::PerDirectory(source_display.clone());
719 let rel_key = if dir.is_empty() {
720 file_name.to_owned()
721 } else {
722 format!("{dir}/{file_name}")
723 };
724
725 match read_in_tree_pattern_file(&path)? {
728 InTreePatternFile::Symlink => {
729 warnings.push(crate::diagnostics::warning_line(&format!(
730 "unable to access '{source_display}': Too many levels of symbolic links"
731 )));
732 return Ok(Vec::new());
733 }
734 InTreePatternFile::Content(content) => {
735 return parse_gitignore_content(&content, source, dir, warnings);
736 }
737 InTreePatternFile::Missing => {}
738 }
739
740 if let Some(ix) = index {
741 if let Some(entry) = ix.get(rel_key.as_bytes(), 0) {
742 if let Ok(obj) = repo.odb().read(&entry.oid) {
743 if obj.kind == ObjectKind::Blob {
744 if let Ok(text) = std::str::from_utf8(&obj.data) {
745 return parse_gitignore_content(text, source, dir, warnings);
746 }
747 }
748 }
749 }
750 }
751
752 Ok(Vec::new())
753}
754
755fn parse_gitignore_content(
756 content: &str,
757 source: IgnoreSource,
758 base_dir: &str,
759 _warnings: &mut Vec<String>,
760) -> Result<Vec<IgnoreRule>> {
761 let mut rules = Vec::new();
762 for (idx, line) in content.split('\n').enumerate() {
765 let line = if idx == 0 {
766 line.strip_prefix('\u{feff}').unwrap_or(line)
767 } else {
768 line
769 };
770 if let Some(rule) = parse_rule_line(line, idx + 1, source.clone(), base_dir) {
771 rules.push(rule);
772 }
773 }
774 Ok(rules)
775}
776
777fn load_rules_from_file(
778 path: &Path,
779 source: IgnoreSource,
780 base_dir: String,
781 deny_symlink_gitignore: bool,
782 warnings: &mut Vec<String>,
783) -> Result<Vec<IgnoreRule>> {
784 if deny_symlink_gitignore && path.exists() {
785 if let Ok(meta) = fs::symlink_metadata(path) {
786 if meta.file_type().is_symlink() {
787 warnings.push(crate::diagnostics::warning_line(&format!(
788 "unable to access '{}': Too many levels of symbolic links",
789 source
790 )));
791 return Ok(Vec::new());
792 }
793 }
794 }
795 if path.exists() {
796 crate::worktree_rules::record_ignore_pattern_file_read(path);
797 }
798 let Some(content) = read_optional_text(path)? else {
799 return Ok(Vec::new());
800 };
801 parse_gitignore_content(&content, source, &base_dir, warnings)
802}
803
804fn trim_trailing_spaces_git(line: &str) -> &str {
809 let bytes = line.as_bytes();
810 let mut last_space_start: Option<usize> = None;
811 let mut i = 0usize;
812 while let Some(&byte) = bytes.get(i) {
813 match byte {
814 b' ' => {
815 last_space_start.get_or_insert(i);
816 i += 1;
817 }
818 b'\\' => {
819 last_space_start = None;
820 i += 2;
821 }
822 _ => {
823 last_space_start = None;
824 i += 1;
825 }
826 }
827 }
828 last_space_start.map_or(line, |start| &line[..start])
830}
831
832fn parse_rule_line(
833 line: &str,
834 line_number: usize,
835 source: IgnoreSource,
836 base_dir: &str,
837) -> Option<IgnoreRule> {
838 let pattern_text = trim_trailing_spaces_git(line.strip_suffix('\r').unwrap_or(line));
839 if pattern_text.is_empty() || pattern_text.starts_with('#') {
840 return None;
841 }
842 let (negative, raw) = match pattern_text.strip_prefix('!') {
843 Some(rest) => (true, rest),
844 None => (false, pattern_text),
845 };
846 let (anchored, raw) = match raw.strip_prefix('/') {
847 Some(rest) => (true, rest),
848 None => (false, raw),
849 };
850 let (directory_only, body) = match raw.strip_suffix('/') {
851 Some(rest) => (true, rest),
852 None => (false, raw),
853 };
854 if raw.is_empty() || body.is_empty() {
855 return None;
856 }
857 Some(IgnoreRule {
858 source,
859 line_number,
860 pattern_text: pattern_text.to_owned(),
861 negative,
862 directory_only,
863 anchored,
864 has_slash: body.contains('/'),
865 body: body.to_owned(),
866 base_dir: base_dir.to_owned(),
867 match_kind: classify_ignore_pattern(body),
868 })
869}
870
871fn classify_ignore_pattern(body: &str) -> IgnorePatternKind {
872 if body.contains('\\') || body.contains(['?', '[']) {
875 return IgnorePatternKind::Wild;
876 }
877 if !body.contains('*') {
878 return IgnorePatternKind::Literal(body.to_owned());
879 }
880 if let Some(rest) = body.strip_prefix('*') {
881 if !rest.contains('*') && !rest.contains('/') {
882 return IgnorePatternKind::Suffix(rest.to_owned());
883 }
884 }
885 if let Some(rest) = body.strip_suffix('*') {
886 if !rest.contains('*') && !rest.contains('/') {
887 return IgnorePatternKind::Prefix(rest.to_owned());
888 }
889 }
890 IgnorePatternKind::Wild
891}
892
893fn base_dir_depth(base: &str) -> usize {
894 if base.is_empty() {
895 return 0;
896 }
897 base.split('/').count()
898}
899
900fn rule_matches(rule: &IgnoreRule, repo_rel_path: &str, is_dir: bool, ignore_case: bool) -> bool {
901 let Some(target) = MatchTarget::new(&rule.base_dir, repo_rel_path, ignore_case) else {
902 return false;
903 };
904 if ignore_case {
905 target.matches::<true>(rule, is_dir)
906 } else {
907 target.matches::<false>(rule, is_dir)
908 }
909}
910
911fn last_match_in<'a>(
916 base_dir: &str,
917 rules: &'a [IgnoreRule],
918 path: &str,
919 is_dir: bool,
920 ignore_case: bool,
921) -> Option<&'a IgnoreRule> {
922 if rules.is_empty() {
923 return None;
924 }
925 let target = MatchTarget::new(base_dir, path, ignore_case)?;
926 if ignore_case {
927 rules
928 .iter()
929 .rev()
930 .find(|rule| target.matches::<true>(rule, is_dir))
931 } else {
932 rules
933 .iter()
934 .rev()
935 .find(|rule| target.matches::<false>(rule, is_dir))
936 }
937}
938
939struct MatchTarget<'p> {
941 rel: &'p str,
943 basename: &'p str,
945}
946
947impl<'p> MatchTarget<'p> {
948 fn new(base_dir: &str, path: &'p str, ignore_case: bool) -> Option<Self> {
950 let rel = strip_base(base_dir, path, ignore_case)?;
951 if rel.is_empty() {
952 return None;
953 }
954 let basename = rel.rsplit_once('/').map_or(rel, |(_, name)| name);
955 Some(Self { rel, basename })
956 }
957
958 fn matches<const FOLD: bool>(&self, rule: &IgnoreRule, is_dir: bool) -> bool {
967 if rule.directory_only && !is_dir {
970 return false;
971 }
972 if rule.has_slash || rule.anchored {
973 return rule_body_matches(rule, self.rel, true, FOLD);
974 }
975 rule_body_matches(rule, self.basename, false, FOLD)
976 }
977}
978
979fn is_tracked(index: Option<&Index>, repo_rel_path: &str) -> bool {
980 let Some(index) = index else {
981 return false;
982 };
983 if repo_rel_path.ends_with('/') {
986 return false;
987 }
988 index.get(repo_rel_path.as_bytes(), 0).is_some()
989}
990
991fn worktree_path_is_existing_file(repo: &Repository, repo_rel_path: &str) -> bool {
993 let Some(work_tree) = repo.work_tree() else {
994 return false;
995 };
996 let path = work_tree.join(repo_rel_path);
997 path.metadata().map(|m| m.is_file()).unwrap_or(false)
998}
999
1000fn probe_query_path(
1008 work_tree: Option<&Path>,
1009 repo_rel_path: &str,
1010 scratch: &mut PathBuf,
1011) -> (bool, bool) {
1012 if repo_rel_path.ends_with('/') {
1013 return (true, false);
1014 }
1015 let Some(work_tree) = work_tree else {
1016 return (false, false);
1017 };
1018 scratch.as_mut_os_string().clear();
1019 scratch.push(work_tree);
1020 scratch.push(repo_rel_path);
1021 match scratch.metadata() {
1022 Ok(meta) => (meta.is_dir(), true),
1023 Err(err) => (
1025 false,
1026 cfg!(unix) && err.kind() == std::io::ErrorKind::NotFound,
1027 ),
1028 }
1029}
1030
1031impl Repository {
1032 pub fn ignore_check(&self, paths: &[String], index: &Index) -> Result<Vec<IgnoreCheck>> {
1047 let mut matcher = IgnoreMatcher::from_repository(self)?;
1048 let mut out = Vec::with_capacity(paths.len());
1049 let mut scratch = PathBuf::new();
1050 for path in paths {
1051 let (is_dir, ancestors_are_dirs) =
1052 probe_query_path(self.work_tree(), path, &mut scratch);
1053 let (ignored, matched) = matcher.check_path_with_ancestors(
1054 self,
1055 Some(index),
1056 path,
1057 is_dir,
1058 ancestors_are_dirs,
1059 )?;
1060 out.push(IgnoreCheck {
1061 path: path.clone(),
1062 ignored,
1063 matched,
1064 });
1065 }
1066 Ok(out)
1067 }
1068}
1069
1070enum InTreePatternFile {
1072 Missing,
1073 Symlink,
1074 Content(String),
1075}
1076
1077fn read_in_tree_pattern_file(path: &Path) -> Result<InTreePatternFile> {
1080 #[cfg(unix)]
1081 let opened = {
1082 use std::os::unix::fs::OpenOptionsExt;
1083 fs::OpenOptions::new()
1084 .read(true)
1085 .custom_flags(libc::O_NOFOLLOW)
1086 .open(path)
1087 };
1088 #[cfg(not(unix))]
1089 let opened = match fs::symlink_metadata(path) {
1090 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1091 return Ok(InTreePatternFile::Missing);
1092 }
1093 Err(err) => return Err(Error::Io(err)),
1094 Ok(meta) if meta.file_type().is_symlink() => return Ok(InTreePatternFile::Symlink),
1095 Ok(_) => fs::File::open(path),
1096 };
1097 let mut file = match opened {
1098 Ok(file) => file,
1099 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1100 return Ok(InTreePatternFile::Missing);
1101 }
1102 Err(_) if fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) => {
1103 return Ok(InTreePatternFile::Symlink);
1104 }
1105 Err(err) => return Err(Error::Io(err)),
1106 };
1107 let mut content = String::new();
1108 std::io::Read::read_to_string(&mut file, &mut content).map_err(Error::Io)?;
1109 Ok(InTreePatternFile::Content(content))
1110}
1111
1112fn read_optional_text(path: &Path) -> Result<Option<String>> {
1113 match fs::read_to_string(path) {
1114 Ok(content) => Ok(Some(content)),
1115 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1116 Err(err) => Err(Error::Io(err)),
1117 }
1118}
1119
1120fn strip_base<'a>(base: &str, path: &'a str, ignore_case: bool) -> Option<&'a str> {
1123 if base.is_empty() {
1124 return Some(path);
1125 }
1126 if !starts_with_fold(path, base, ignore_case) {
1127 return None;
1128 }
1129 match path.as_bytes().get(base.len()) {
1130 None => Some(""),
1131 Some(b'/') => path.get(base.len() + 1..),
1133 Some(_) => None,
1134 }
1135}
1136
1137fn eq_fold(text: &str, pattern: &str, ignore_case: bool) -> bool {
1138 if ignore_case {
1139 text.eq_ignore_ascii_case(pattern)
1140 } else {
1141 text == pattern
1142 }
1143}
1144
1145fn starts_with_fold(text: &str, prefix: &str, ignore_case: bool) -> bool {
1146 if !ignore_case {
1147 return text.starts_with(prefix);
1148 }
1149 text.as_bytes()
1150 .get(..prefix.len())
1151 .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_bytes()))
1152}
1153
1154fn ends_with_fold(text: &str, suffix: &str, ignore_case: bool) -> bool {
1155 if !ignore_case {
1156 return text.ends_with(suffix);
1157 }
1158 text.len()
1159 .checked_sub(suffix.len())
1160 .and_then(|start| text.as_bytes().get(start..))
1161 .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix.as_bytes()))
1162}
1163
1164fn parent_dir(path: &str) -> &str {
1165 match path.rsplit_once('/') {
1166 Some((parent, _)) => parent,
1167 None => "",
1168 }
1169}
1170
1171fn pattern_matches_kind(
1172 kind: &IgnorePatternKind,
1173 pattern: &str,
1174 text: &str,
1175 pathname: bool,
1176 ignore_case: bool,
1177) -> bool {
1178 match kind {
1179 IgnorePatternKind::Literal(lit) => eq_fold(text, lit, ignore_case),
1180 IgnorePatternKind::Prefix(prefix) => starts_with_fold(text, prefix, ignore_case),
1181 IgnorePatternKind::Suffix(suffix) => ends_with_fold(text, suffix, ignore_case),
1182 IgnorePatternKind::Wild => {
1183 let mut flags = if pathname { WM_PATHNAME } else { 0 };
1184 if ignore_case {
1185 flags |= WM_CASEFOLD;
1186 }
1187 wildmatch(pattern.as_bytes(), text.as_bytes(), flags)
1188 }
1189 }
1190}
1191
1192fn rule_body_matches(rule: &IgnoreRule, text: &str, pathname: bool, ignore_case: bool) -> bool {
1193 pattern_matches_kind(&rule.match_kind, &rule.body, text, pathname, ignore_case)
1194}
1195
1196#[derive(Debug, Clone)]
1198struct SparsePattern {
1199 negative: bool,
1200 directory_only: bool,
1201 anchored: bool,
1202 has_slash: bool,
1203 body: String,
1204}
1205
1206impl SparsePattern {
1207 fn from_line(line: &str) -> Option<Self> {
1208 let raw_line = trim_trailing_spaces_git(line.strip_suffix('\r').unwrap_or(line));
1209 if raw_line.is_empty() || raw_line.starts_with('#') {
1210 return None;
1211 }
1212
1213 let mut raw = raw_line.to_owned();
1214
1215 let mut negative = false;
1216 if let Some(rest) = raw.strip_prefix('!') {
1217 negative = true;
1218 raw = rest.to_owned();
1219 }
1220 if raw.is_empty() {
1221 return None;
1222 }
1223
1224 let mut directory_only = false;
1227 if raw.len() > 1 && raw.ends_with('/') {
1228 directory_only = true;
1229 raw.pop();
1230 }
1231 if raw.is_empty() {
1232 return None;
1233 }
1234
1235 let mut anchored = false;
1236 if let Some(rest) = raw.strip_prefix('/') {
1237 anchored = true;
1238 raw = rest.to_owned();
1239 }
1240 if raw.is_empty() && anchored && directory_only {
1243 raw = "*".to_owned();
1244 directory_only = false;
1245 } else if raw.is_empty() {
1246 return None;
1247 }
1248
1249 let has_slash = raw.contains('/');
1250 Some(Self {
1251 negative,
1252 directory_only,
1253 anchored,
1254 has_slash,
1255 body: raw,
1256 })
1257 }
1258}
1259
1260fn sparse_pattern_matches(p: &SparsePattern, pathname: &str, as_directory: bool) -> bool {
1266 if p.directory_only && !as_directory {
1272 return false;
1273 }
1274 if p.directory_only && p.anchored && !p.has_slash && p.body == "*" {
1278 let trimmed = pathname.trim_end_matches('/');
1282 return !trimmed.contains('/');
1283 }
1284 if !p.has_slash && !p.anchored {
1285 sparse_unanchored_basename_matches(&p.body, pathname)
1286 } else if p.anchored && !p.has_slash && p.body == "*" && !p.directory_only {
1287 !pathname.contains('/')
1291 } else {
1292 wildmatch(p.body.as_bytes(), pathname.as_bytes(), WM_PATHNAME)
1293 }
1294}
1295
1296fn sparse_pattern_matches_path(p: &SparsePattern, pathname: &str) -> bool {
1297 sparse_pattern_matches(p, pathname, false)
1298}
1299
1300#[must_use]
1302pub fn parse_sparse_patterns_from_blob(content: &str) -> Vec<String> {
1303 let mut patterns = Vec::new();
1304 for line in content.lines() {
1305 let t = line.trim_end_matches('\r');
1306 if t.is_empty() || t.starts_with('#') {
1307 continue;
1308 }
1309 patterns.push(t.to_owned());
1310 }
1311 patterns
1312}
1313
1314fn sparse_list_last_match(
1315 pathname: &str,
1316 as_directory: bool,
1317 parsed: &[SparsePattern],
1318) -> Option<bool> {
1319 for p in parsed.iter().rev() {
1320 if sparse_pattern_matches(p, pathname, as_directory) {
1321 return Some(!p.negative);
1322 }
1323 }
1324 None
1325}
1326
1327#[must_use]
1333pub fn path_in_sparse_checkout(path: &str, lines: &[String], work_tree: Option<&Path>) -> bool {
1334 ParsedSparsePatterns::parse(lines).includes(path, work_tree)
1335}
1336
1337#[derive(Debug, Clone)]
1339pub(crate) struct ParsedSparsePatterns(Vec<SparsePattern>);
1340
1341impl ParsedSparsePatterns {
1342 pub(crate) fn parse(lines: &[String]) -> Self {
1344 Self(
1345 lines
1346 .iter()
1347 .filter_map(|l| SparsePattern::from_line(l))
1348 .collect(),
1349 )
1350 }
1351
1352 pub(crate) fn includes(&self, path: &str, work_tree: Option<&Path>) -> bool {
1354 if path.is_empty() || self.0.is_empty() {
1355 return true;
1356 }
1357
1358 let mut end = path.len();
1359 let mut as_directory = false;
1360
1361 loop {
1362 let pathname = &path[..end];
1363 let dtype_is_dir =
1364 work_tree.is_some_and(|wt| wt.join(pathname).is_dir()) || as_directory;
1365
1366 match sparse_list_last_match(pathname, dtype_is_dir, &self.0) {
1367 Some(true) => return true,
1368 Some(false) => return false,
1369 None => {
1370 let Some(slash) = path[..end].rfind('/') else {
1371 return false;
1374 };
1375 end = slash;
1376 as_directory = true;
1377 }
1378 }
1379 }
1380 }
1381}
1382
1383#[must_use]
1391pub fn path_matches_sparse_pattern_list(pathname: &str, lines: &[String]) -> Option<bool> {
1392 let parsed: Vec<SparsePattern> = lines
1393 .iter()
1394 .filter_map(|l| SparsePattern::from_line(l))
1395 .collect();
1396 if parsed.is_empty() {
1397 return None;
1398 }
1399 for p in parsed.iter().rev() {
1400 if sparse_pattern_matches_path(p, pathname) {
1401 return Some(!p.negative);
1402 }
1403 }
1404 None
1405}
1406
1407fn sparse_unanchored_basename_matches(pat: &str, path: &str) -> bool {
1408 let basename = path.rsplit('/').next().unwrap_or(path);
1409 wildmatch(pat.as_bytes(), basename.as_bytes(), 0)
1410}
1411
1412#[must_use]
1414pub fn submodule_containing_path(repo_rel_path: &str, index: &Index) -> Option<String> {
1415 let bytes = repo_rel_path.as_bytes();
1416 if bytes.is_empty() {
1417 return None;
1418 }
1419 let mut best: Option<String> = None;
1420 let mut end = 0usize;
1421 while end < bytes.len() {
1422 let Some(slash) = bytes[end..].iter().position(|&b| b == b'/') else {
1423 break;
1424 };
1425 end += slash;
1426 if let Some(entry) = index.get(&bytes[..end], 0) {
1427 if entry.mode == MODE_GITLINK {
1428 best = Some(String::from_utf8_lossy(&bytes[..end]).into_owned());
1429 }
1430 }
1431 end += 1;
1432 }
1433 best
1434}
1435
1436pub fn normalize_repo_relative(repo: &Repository, cwd: &Path, path: &str) -> Result<String> {
1451 RepoPathNormalizer::new(repo, cwd)?.normalize(path)
1452}
1453
1454#[derive(Debug)]
1481pub struct RepoPathNormalizer<'a> {
1482 work_tree: &'a Path,
1483 cwd: &'a Path,
1484 cwd_rel: Option<String>,
1486}
1487
1488impl<'a> RepoPathNormalizer<'a> {
1489 pub fn new(repo: &'a Repository, cwd: &'a Path) -> Result<Self> {
1501 let Some(work_tree) = repo.work_tree() else {
1502 return Err(Error::PathError(
1503 "this operation must be run in a work tree".to_owned(),
1504 ));
1505 };
1506 let cwd_rel = normalize_path(cwd)
1507 .strip_prefix(work_tree)
1508 .ok()
1509 .map(crate::git_path::path_to_git_slash);
1510 Ok(Self {
1511 work_tree,
1512 cwd,
1513 cwd_rel,
1514 })
1515 }
1516
1517 pub fn normalize(&self, path: &str) -> Result<String> {
1536 if path.starts_with(':') {
1537 return Ok(path.to_owned());
1538 }
1539 match self.normalize_lexically(path) {
1540 Some(rel) => Ok(rel),
1541 None => self.normalize_via_filesystem_paths(path),
1542 }
1543 }
1544
1545 fn normalize_lexically(&self, path: &str) -> Option<String> {
1548 let cwd_rel = self.cwd_rel.as_deref()?;
1549 if cfg!(windows) || Path::new(path).is_absolute() {
1550 return None;
1551 }
1552 let mut rel = String::with_capacity(cwd_rel.len() + path.len() + 1);
1553 rel.push_str(cwd_rel);
1554 for component in path.split('/') {
1555 match component {
1556 "" | "." => {}
1557 ".." => {
1558 if rel.is_empty() {
1559 return None;
1560 }
1561 rel.truncate(rel.rfind('/').unwrap_or(0));
1562 }
1563 name => {
1564 if !rel.is_empty() {
1565 rel.push('/');
1566 }
1567 rel.push_str(name);
1568 }
1569 }
1570 }
1571 if path.ends_with('/') && !rel.is_empty() {
1572 rel.push('/');
1573 }
1574 Some(rel)
1575 }
1576
1577 fn normalize_via_filesystem_paths(&self, path: &str) -> Result<String> {
1578 let work_tree = self.work_tree;
1579 let query_as_dir = path.ends_with('/');
1580 let input = Path::new(path);
1581 let combined = if input.is_absolute() {
1582 input.to_path_buf()
1583 } else {
1584 self.cwd.join(input)
1585 };
1586 let normalized = normalize_path(&combined);
1587 let mut rel = match normalized.strip_prefix(work_tree) {
1591 Ok(relative) => crate::git_path::path_to_git_slash(relative),
1592 Err(_) => {
1593 crate::git_path::strip_worktree_prefix(&normalized, work_tree).ok_or_else(|| {
1594 Error::PathError(format!("path '{path}' is outside repository work tree"))
1595 })?
1596 }
1597 };
1598 if query_as_dir && !rel.is_empty() && !rel.ends_with('/') {
1599 rel.push('/');
1600 }
1601 Ok(rel)
1602 }
1603}
1604
1605fn normalize_path(path: &Path) -> PathBuf {
1606 let mut out = PathBuf::new();
1607 for component in path.components() {
1608 match component {
1609 Component::CurDir => {}
1610 Component::ParentDir => {
1611 out.pop();
1612 }
1613 other => out.push(other.as_os_str()),
1614 }
1615 }
1616 out
1617}
1618
1619#[cfg(test)]
1620mod normalization_tests {
1621 use super::*;
1622
1623 #[test]
1624 fn bare_repository_queries_return_a_path_error() {
1625 let dir = tempfile::tempdir().unwrap();
1626 let repo = crate::repo::init_repository(
1627 dir.path(),
1628 true,
1629 "main",
1630 None,
1631 crate::RefStorageFormat::Files,
1632 )
1633 .unwrap();
1634 assert!(matches!(
1635 normalize_repo_relative(&repo, dir.path(), "missing/file"),
1636 Err(Error::PathError(_))
1637 ));
1638 }
1639
1640 #[test]
1641 fn lexical_route_agrees_with_filesystem_route() {
1642 let dir = tempfile::tempdir().unwrap();
1643 let root = dir.path();
1644 crate::repo::init_repository(root, false, "main", None, crate::RefStorageFormat::Files)
1645 .unwrap();
1646 let options = crate::environment::RepositoryOptions::empty().with_work_tree(root);
1647 let repo = Repository::open(&root.join(".git"), &options).unwrap();
1648 let root = repo.work_tree().unwrap().to_path_buf();
1649 let inputs = [
1650 "",
1651 ".",
1652 "./",
1653 "a",
1654 "a/",
1655 "a//b",
1656 "./a/./b/",
1657 "a/../b",
1658 "a/b/../../c",
1659 "..",
1660 "../",
1661 "../x",
1662 "../../x/y/",
1663 "x/../../y",
1664 "café/new.txt",
1665 "...",
1666 ".hidden/..x",
1667 ];
1668 for cwd in [root.clone(), root.join("sub"), root.join("sub/deeper")] {
1669 let normalizer = RepoPathNormalizer::new(&repo, &cwd).unwrap();
1670 assert!(normalizer.cwd_rel.is_some());
1671 for input in inputs {
1672 let general = normalizer.normalize_via_filesystem_paths(input);
1673 let lexical = normalizer.normalize_lexically(input);
1674 match (&general, lexical) {
1675 (Ok(expected), Some(got)) => assert_eq!(&got, expected, "{cwd:?} {input}"),
1676 (_, None) => {}
1677 (Err(e), Some(got)) => panic!("{cwd:?} {input}: lexical {got}, general {e}"),
1678 }
1679 assert_eq!(
1680 normalizer.normalize(input).ok(),
1681 general.ok(),
1682 "{cwd:?} {input}"
1683 );
1684 }
1685 }
1686 let from_root = RepoPathNormalizer::new(&repo, &root).unwrap();
1687 assert_eq!(from_root.normalize("a/../b/").unwrap(), "b/");
1688 assert_eq!(from_root.normalize(":(glob)x").unwrap(), ":(glob)x");
1689 assert!(from_root.normalize("../x").is_err());
1690 let absolute = root.join("sub/f");
1691 assert_eq!(
1692 from_root.normalize(absolute.to_str().unwrap()).unwrap(),
1693 "sub/f"
1694 );
1695
1696 let outside = dir.path().parent().unwrap();
1697 let normalizer = RepoPathNormalizer::new(&repo, outside).unwrap();
1698 assert!(normalizer.cwd_rel.is_none());
1699 let name = root.file_name().unwrap().to_str().unwrap();
1700 assert_eq!(normalizer.normalize(&format!("{name}/x/")).unwrap(), "x/");
1701 assert!(normalizer.normalize("elsewhere").is_err());
1702 }
1703}
1704
1705#[cfg(test)]
1706mod per_directory_load_tests {
1707 use super::*;
1708
1709 #[test]
1710 fn known_directory_reads_its_exclude_file_on_first_query() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let root = dir.path();
1713 let repo =
1714 crate::repo::init_repository(root, false, "main", None, crate::RefStorageFormat::Files)
1715 .unwrap();
1716 fs::create_dir_all(root.join("d/sub")).unwrap();
1717 fs::write(root.join("d/.gitignore"), "*.tmp\n").unwrap();
1718
1719 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
1720 matcher.note_directory_known("d");
1721 matcher.note_directory_known("d/sub");
1722 assert!(
1723 matcher.gitignore_cache.is_empty(),
1724 "entering a directory must not read its exclude file"
1725 );
1726 assert!(matcher.known_worktree_directories.contains("d/sub"));
1727
1728 assert!(matcher
1729 .is_path_ignored(&repo, None, "d/sub/x.tmp", false)
1730 .unwrap());
1731 assert!(!matcher
1732 .is_path_ignored(&repo, None, "d/sub/x.txt", false)
1733 .unwrap());
1734 assert!(matcher.gitignore_cache.contains_key("d"));
1735 assert!(matcher.gitignore_cache.contains_key("d/sub"));
1736 }
1737}
1738
1739#[cfg(test)]
1740mod sparse_checkout_tests {
1741 use super::*;
1742
1743 #[test]
1744 fn non_cone_default_init_patterns() {
1745 let lines = vec!["/*".into(), "!/*/".into()];
1746 assert!(path_in_sparse_checkout("a", &lines, None));
1747 assert!(!path_in_sparse_checkout("folder1/a", &lines, None));
1748 let wt = std::env::temp_dir().join("grit-sparse-wt-test");
1749 let _ = std::fs::create_dir_all(wt.join("folder1"));
1750 let _ = std::fs::write(wt.join("a"), b"x");
1751 assert!(!path_in_sparse_checkout("folder1/a", &lines, Some(&wt)));
1752 assert!(!path_in_sparse_checkout("folder1", &lines, Some(&wt)));
1753 }
1754
1755 #[test]
1756 fn unanchored_glob_matches_path_components() {
1757 let lines = vec!["/*".into(), "!/*/".into(), "*folder*".into()];
1758 assert!(path_in_sparse_checkout("folder1/a", &lines, None));
1759 assert!(path_in_sparse_checkout("folder2/a", &lines, None));
1760 assert!(!path_in_sparse_checkout("deep/a", &lines, None));
1761 }
1762
1763 #[test]
1764 fn non_cone_src_nested_under_included_dir_with_negated_slash_slash() {
1765 let lines = vec!["/*".into(), "!/*/".into(), "/src/".into()];
1766 assert!(path_in_sparse_checkout("a.txt", &lines, None));
1767 assert!(path_in_sparse_checkout("src/z", &lines, None));
1768 assert!(path_in_sparse_checkout("src/deep/w", &lines, None));
1769 assert!(!path_in_sparse_checkout("d1/x", &lines, None));
1770 assert!(!path_in_sparse_checkout("d1/sub/y", &lines, None));
1771 }
1772
1773 #[test]
1774 fn unanchored_star_with_negated_other_includes_other_slash_q() {
1775 let lines = vec!["*".into(), "!other".into()];
1776 assert!(path_in_sparse_checkout("other/q", &lines, None));
1777 assert!(!path_in_sparse_checkout("other", &lines, None));
1778 }
1779}
1780
1781#[cfg(test)]
1782mod gitignore_glob_tests {
1783 use super::*;
1784
1785 fn pathname_pattern_matches(pattern: &str, text: &str) -> bool {
1786 pattern_matches_kind(
1787 &classify_ignore_pattern(pattern),
1788 pattern,
1789 text,
1790 true,
1791 false,
1792 )
1793 }
1794
1795 fn rule(line: &str, base_dir: &str) -> IgnoreRule {
1796 parse_rule_line(line, 1, IgnoreSource::InfoExclude, base_dir).unwrap()
1797 }
1798
1799 #[test]
1800 fn lines_lose_at_most_one_trailing_cr() {
1801 let rules = |content: &str| {
1802 parse_gitignore_content(content, IgnoreSource::InfoExclude, "", &mut Vec::new())
1803 .unwrap()
1804 };
1805 let [crlf] = rules("a\r\n").try_into().unwrap();
1806 assert!(rule_matches(&crlf, "a", false, false));
1807 let [double_cr] = rules("a\r\r\n").try_into().unwrap();
1808 assert!(rule_matches(&double_cr, "a\r", false, false));
1809 assert!(!rule_matches(&double_cr, "a", false, false));
1810 let [leading_cr] = rules("\ra\n").try_into().unwrap();
1811 assert!(rule_matches(&leading_cr, "\ra", false, false));
1812 assert!(rules("a\nb").len() == 2);
1813 }
1814
1815 #[test]
1816 fn trailing_spaces_are_trimmed_unless_escaped() {
1817 for (line, expected) in [
1818 ("a ", "a"),
1819 ("a\\ ", "a\\ "),
1820 ("a\\ ", "a\\ "),
1821 ("a\\", "a\\"),
1822 ("é b ", "é b"),
1823 (" ", ""),
1824 ("a \\\\ ", "a \\\\"),
1825 ] {
1826 assert_eq!(trim_trailing_spaces_git(line), expected, "{line:?}");
1827 }
1828 for empty in ["!", "/", "!/", "//", "# x", " ", "\r"] {
1829 let parsed = parse_rule_line(empty, 1, IgnoreSource::InfoExclude, "");
1830 assert!(parsed.is_none(), "{empty:?}");
1831 }
1832 let r = rule("!/a/b/ ", "");
1833 assert!(r.negative && r.anchored && r.directory_only && r.has_slash);
1834 assert_eq!(
1835 (r.pattern_text.as_str(), r.body.as_str()),
1836 ("!/a/b/", "a/b")
1837 );
1838 }
1839
1840 #[test]
1841 fn every_pattern_kind_folds_ascii_case_only_with_ignore_case() {
1842 for (pattern, kind, path) in [
1843 ("README", "literal", "readme"),
1844 ("Target*", "prefix", "target-debug"),
1845 ("*.LOG", "suffix", "debug.log"),
1846 ("[Dd]EBUG?.txt", "wild", "debug1.TXT"),
1847 ] {
1848 let r = rule(pattern, "");
1849 assert!(!rule_matches(&r, path, false, false), "{kind}: {pattern}");
1850 assert!(rule_matches(&r, path, false, true), "{kind}: {pattern}");
1851 assert!(rule_matches(&r, &format!("sub/{path}"), false, true));
1852 }
1853 assert!(!rule_matches(&rule("*.LOG", ""), "x.lag", false, true));
1854 assert!(!rule_matches(&rule("Target*", ""), "tar", false, true));
1855 assert!(!rule_matches(&rule("*.LOG", ""), "LOG", false, true));
1856 assert!(!rule_matches(&rule("*É", ""), "é", false, true));
1858 assert!(!rule_matches(&rule("Über", ""), "über", false, true));
1859 assert!(rule_matches(&rule("Über", ""), "ÜBER", false, true));
1860 }
1861
1862 #[test]
1863 fn directory_and_anchored_rules_fold_case() {
1864 let dir_only = rule("Build/", "");
1865 assert!(rule_matches(&dir_only, "build", true, true));
1866 assert!(rule_matches(&dir_only, "src/BUILD", true, true));
1867 assert!(!rule_matches(&dir_only, "build", true, false));
1868 assert!(!rule_matches(&dir_only, "build", false, true));
1869 let anchored = rule("/Docs/*.PDF", "");
1870 assert!(rule_matches(&anchored, "docs/manual.pdf", false, true));
1871 assert!(!rule_matches(&anchored, "docs/sub/manual.pdf", false, true));
1872 assert!(!rule_matches(&anchored, "docs/manual.pdf", false, false));
1873 }
1874
1875 #[test]
1876 fn nested_gitignore_base_dir_folds_case() {
1877 let nested = rule("*.tmp", "Sub/Dir");
1878 assert!(rule_matches(&nested, "Sub/Dir/a.tmp", false, false));
1879 assert!(!rule_matches(&nested, "sub/dir/a.tmp", false, false));
1880 assert!(rule_matches(&nested, "sub/dir/a.tmp", false, true));
1881 assert!(!rule_matches(&nested, "sub/dirx/a.tmp", false, true));
1882 assert!(!rule_matches(&nested, "sub/di", false, true));
1883 assert_eq!(strip_base("Sub", "sub", true), Some(""));
1884 assert_eq!(strip_base("Sub", "SUB/x", true), Some("x"));
1885 assert_eq!(strip_base("Sub", "subway/x", true), None);
1886 assert_eq!(strip_base("Sub", "sub/x", false), None);
1887 }
1888
1889 #[test]
1890 fn precompiled_literal_prefix_suffix_patterns() {
1891 assert!(matches!(
1892 classify_ignore_pattern("build"),
1893 IgnorePatternKind::Literal(_)
1894 ));
1895 assert!(matches!(
1896 classify_ignore_pattern("target*"),
1897 IgnorePatternKind::Prefix(_)
1898 ));
1899 assert!(matches!(
1900 classify_ignore_pattern("*.o"),
1901 IgnorePatternKind::Suffix(_)
1902 ));
1903 assert!(matches!(
1904 classify_ignore_pattern("a*b"),
1905 IgnorePatternKind::Wild
1906 ));
1907 assert!(matches!(
1908 classify_ignore_pattern(r"foo\ bar"),
1909 IgnorePatternKind::Wild
1910 ));
1911 assert!(matches!(
1912 classify_ignore_pattern("foo/*"),
1913 IgnorePatternKind::Wild
1914 ));
1915 assert!(!pathname_pattern_matches("foo/*", "foo/bar/baz"));
1916 assert!(rule_body_matches(
1917 &IgnoreRule {
1918 source: IgnoreSource::InfoExclude,
1919 line_number: 1,
1920 pattern_text: "*.log".into(),
1921 negative: false,
1922 directory_only: false,
1923 anchored: false,
1924 has_slash: false,
1925 body: "*.log".into(),
1926 base_dir: String::new(),
1927 match_kind: IgnorePatternKind::Suffix(".log".into()),
1928 },
1929 "app.log",
1930 false,
1931 false
1932 ));
1933 }
1934
1935 #[test]
1936 fn dir_star_extension_matches_nested_path() {
1937 assert!(!pathname_pattern_matches("doc/*.pdf", "doc/sub/manual.pdf"));
1939 assert!(pathname_pattern_matches("doc/*.pdf", "doc/manual.pdf"));
1940 assert!(!pathname_pattern_matches("doc/*.pdf", "other/manual.pdf"));
1941 assert!(pathname_pattern_matches(
1942 "doc/**/*.pdf",
1943 "doc/sub/manual.pdf"
1944 ));
1945 }
1946
1947 #[test]
1948 fn dir_star_extension_unexpanded_when_parent_has_glob() {
1949 assert!(!pathname_pattern_matches("*/foo/*.pdf", "a/foo/sub/x.pdf"));
1950 }
1951
1952 #[test]
1953 fn nested_dir_star_extension() {
1954 assert!(!pathname_pattern_matches("foo/bar/*.c", "foo/bar/baz/x.c"));
1955 }
1956}
1957
1958#[cfg(all(test, unix))]
1959mod per_directory_gitignore_tests {
1960 use super::*;
1961 use crate::repo::init_repository;
1962 use crate::RefStorageFormat;
1963 use std::os::unix::ffi::OsStringExt;
1964 use std::os::unix::fs::symlink;
1965 use tempfile::TempDir;
1966
1967 fn ignored_with_warnings(repo: &Repository, path: &str) -> (bool, Vec<String>) {
1968 let mut matcher = IgnoreMatcher::from_repository(repo).unwrap();
1969 let ignored = matcher.is_path_ignored(repo, None, path, false).unwrap();
1970 (ignored, matcher.take_warnings())
1971 }
1972
1973 #[test]
1974 fn symlinked_gitignore_is_skipped_with_warning_and_plain_one_applies() {
1975 let dir = TempDir::new().unwrap();
1976 let root = dir.path();
1977 let repo = init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
1978 fs::create_dir_all(root.join("plain")).unwrap();
1979 fs::create_dir_all(root.join("to_file")).unwrap();
1980 fs::create_dir_all(root.join("to_dir")).unwrap();
1981 fs::create_dir_all(root.join("none")).unwrap();
1982 fs::write(root.join("plain/.gitignore"), "*.log\n").unwrap();
1983 fs::write(root.join("rules"), "*.log\n").unwrap();
1984 symlink(root.join("rules"), root.join("to_file/.gitignore")).unwrap();
1985 symlink(root.join("plain"), root.join("to_dir/.gitignore")).unwrap();
1986 fs::create_dir_all(root.join("dangling")).unwrap();
1987 symlink(root.join("no-such-rules"), root.join("dangling/.gitignore")).unwrap();
1988 fs::create_dir_all(root.join("to_fifo")).unwrap();
1989 let fifo = std::ffi::CString::new(root.join("fifo").into_os_string().into_vec()).unwrap();
1990 assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o644) }, 0);
1992 symlink(root.join("fifo"), root.join("to_fifo/.gitignore")).unwrap();
1993
1994 assert_eq!(ignored_with_warnings(&repo, "plain/a.log"), (true, vec![]));
1995 assert_eq!(ignored_with_warnings(&repo, "none/a.log"), (false, vec![]));
1996 let (done_tx, done_rx) = std::sync::mpsc::channel();
1997 let fifo_root = root.to_path_buf();
1998 std::thread::spawn(move || {
1999 let options = crate::environment::RepositoryOptions::empty().with_work_tree(&fifo_root);
2000 let repo = Repository::open(&fifo_root.join(".git"), &options).unwrap();
2001 let _ = done_tx.send(ignored_with_warnings(&repo, "to_fifo/a.log"));
2002 });
2003 let (ignored, warnings) = done_rx
2004 .recv_timeout(std::time::Duration::from_secs(5))
2005 .expect("a .gitignore symlink to a FIFO must be rejected without opening it");
2006 assert!(!ignored);
2007 assert_eq!(warnings.len(), 1, "{warnings:?}");
2008 for dir in ["to_file", "to_dir", "dangling"] {
2009 let (ignored, warnings) = ignored_with_warnings(&repo, &format!("{dir}/a.log"));
2010 assert!(!ignored, "{dir}: symlinked .gitignore must not apply");
2011 assert_eq!(warnings.len(), 1, "{dir}: {warnings:?}");
2012 assert!(warnings[0].contains(&format!("{dir}/.gitignore")));
2013 }
2014 }
2015}
2016
2017#[cfg(test)]
2018mod ignore_case_matcher_tests {
2019 use super::*;
2020 use crate::repo::init_repository;
2021 use crate::RefStorageFormat;
2022 use tempfile::TempDir;
2023
2024 fn issue_repo(ignore_case: bool) -> (TempDir, Repository) {
2025 let dir = TempDir::new().unwrap();
2026 let root = dir.path();
2027 init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
2028 let mut config = fs::read_to_string(root.join(".git/config")).unwrap();
2029 config.push_str(&format!("[core]\n\tignorecase = {ignore_case}\n"));
2030 fs::write(root.join(".git/config"), config).unwrap();
2031 fs::write(root.join(".gitignore"), "Build/\n*.LOG\n!keep.log\n").unwrap();
2032 fs::create_dir_all(root.join("build")).unwrap();
2033 for file in ["build/x.o", "debug.log", "keep.log", "README"] {
2034 fs::write(root.join(file), "x\n").unwrap();
2035 }
2036 let options = crate::environment::RepositoryOptions::empty().with_work_tree(root);
2037 let repo = Repository::open(&root.join(".git"), &options).unwrap();
2038 (dir, repo)
2039 }
2040
2041 fn ignored(matcher: &mut IgnoreMatcher, repo: &Repository, path: &str, is_dir: bool) -> bool {
2042 let fast = matcher.is_path_ignored(repo, None, path, is_dir).unwrap();
2043 let (verbose, _) = matcher.check_path(repo, None, path, is_dir).unwrap();
2044 assert_eq!(fast, verbose, "{path}");
2045 fast
2046 }
2047
2048 #[test]
2049 fn from_repository_honors_core_ignorecase() {
2050 let (_dir, repo) = issue_repo(true);
2051 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2052 assert!(matcher.ignore_case());
2053 assert!(ignored(&mut matcher, &repo, "build", true));
2054 assert!(ignored(&mut matcher, &repo, "build/x.o", false));
2055 assert!(ignored(&mut matcher, &repo, "debug.log", false));
2056 assert!(!ignored(&mut matcher, &repo, "keep.log", false));
2057 assert!(!ignored(&mut matcher, &repo, "KEEP.LOG", false));
2058 assert!(!ignored(&mut matcher, &repo, "README", false));
2059 let (_, matched) = matcher.check_path(&repo, None, "build/x.o", false).unwrap();
2060 assert_eq!(matched.unwrap().pattern_text, "Build/");
2061 }
2062
2063 #[test]
2064 fn case_sensitive_by_default_and_overridable() {
2065 let (_dir, repo) = issue_repo(false);
2066 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2067 assert!(!matcher.ignore_case());
2068 assert!(!ignored(&mut matcher, &repo, "build/x.o", false));
2069 assert!(!ignored(&mut matcher, &repo, "debug.log", false));
2070 assert!(ignored(&mut matcher, &repo, "Build/x.o", false));
2071
2072 let mut folded = matcher.with_ignore_case(true);
2073 assert!(ignored(&mut folded, &repo, "build/x.o", false));
2074 assert!(ignored(&mut folded, &repo, "debug.log", false));
2075 assert!(!IgnoreMatcher::default().ignore_case());
2076 }
2077}
2078
2079#[cfg(test)]
2080mod index_lookup_scaling_tests {
2081 use super::*;
2082 use crate::index::{index_path_cmp, Index, IndexEntry, MODE_REGULAR};
2083 use crate::objects::ObjectId;
2084 use crate::repo::init_repository;
2085 use crate::RefStorageFormat;
2086 use tempfile::TempDir;
2087
2088 fn dummy_oid() -> ObjectId {
2089 ObjectId::from_bytes(&[0u8; 20]).unwrap()
2090 }
2091
2092 fn make_entry(path: &str) -> IndexEntry {
2093 IndexEntry {
2094 ctime_sec: 0,
2095 ctime_nsec: 0,
2096 mtime_sec: 0,
2097 mtime_nsec: 0,
2098 dev: 0,
2099 ino: 0,
2100 mode: MODE_REGULAR,
2101 uid: 0,
2102 gid: 0,
2103 size: 0,
2104 oid: dummy_oid(),
2105 flags: path.len().min(0xFFF) as u16,
2106 flags_extended: None,
2107 path: path.as_bytes().to_vec(),
2108 base_index_pos: 0,
2109 }
2110 }
2111
2112 #[test]
2113 fn tracked_check_path_index_lookups_sublinear_in_index_size() {
2114 const N: usize = 100_000;
2115 const M: usize = 1_000;
2116 let dir = TempDir::new().unwrap();
2117 let repo =
2118 init_repository(dir.path(), false, "main", None, RefStorageFormat::Files).unwrap();
2119 let mut index = Index::new();
2120 for i in 0..N {
2121 index.add_or_replace(make_entry(&format!("tracked/{i:06}.txt")));
2122 }
2123 let mut matcher = IgnoreMatcher::default();
2124 index_path_cmp::reset();
2125 for i in 0..M {
2126 let path = format!("untracked/{i:06}.txt");
2127 let (ignored, _) = matcher
2128 .check_path(&repo, Some(&index), &path, false)
2129 .unwrap();
2130 assert!(!ignored, "untracked path must not be ignored");
2131 }
2132 let cmps = index_path_cmp::take();
2133 let log_n = (N as f64).log2().ceil() as usize;
2134 let bound = 100 * M * log_n;
2135 assert!(
2136 cmps < bound,
2137 "index path comparisons {cmps} should stay under {bound} (N={N}, M={M})"
2138 );
2139 }
2140}
2141
2142#[cfg(test)]
2143mod pattern_list_tests {
2144 use super::IgnorePatternList;
2145
2146 fn ignored(content: &str, path: &str, is_dir: bool) -> bool {
2147 IgnorePatternList::parse(content.as_bytes(), "")
2148 .is_ignored(path, is_dir, false)
2149 .unwrap_or(false)
2150 }
2151
2152 #[test]
2153 fn basename_patterns_match_last_component_only() {
2154 assert!(ignored("foo\n", "foo", false));
2155 assert!(ignored("foo\n", "dir/foo", false));
2156 assert!(!ignored("foo\n", "foo/bar", false));
2157 assert!(ignored("*.o\n", "a/b/c.o", false));
2158 }
2159
2160 #[test]
2161 fn slash_patterns_match_whole_path() {
2162 assert!(ignored("/foo\n", "foo", false));
2163 assert!(!ignored("/foo\n", "dir/foo", false));
2164 assert!(ignored("dir/foo\n", "dir/foo", false));
2165 assert!(!ignored("dir/foo\n", "x/dir/foo", false));
2166 assert!(ignored("dir/*.o\n", "dir/a.o", false));
2167 assert!(!ignored("dir/*.o\n", "dir/sub/a.o", false));
2168 assert!(ignored("**/foo\n", "a/b/foo", false));
2169 assert!(ignored("a/**/b\n", "a/x/y/b", false));
2170 }
2171
2172 #[test]
2173 fn directory_only_patterns_need_a_directory() {
2174 assert!(ignored("foo/\n", "foo", true));
2175 assert!(!ignored("foo/\n", "foo", false));
2176 assert!(!ignored("foo/\n", "foo/bar", false));
2177 }
2178
2179 #[test]
2180 fn last_matching_pattern_wins() {
2181 let list = IgnorePatternList::parse(b"*.log\n!keep.log\n", "");
2182 assert_eq!(list.is_ignored("a.log", false, false), Some(true));
2183 assert_eq!(list.is_ignored("keep.log", false, false), Some(false));
2184 assert_eq!(list.is_ignored("a.txt", false, false), None);
2185 let m = list.last_match("keep.log", false, false).unwrap();
2186 assert_eq!((m.line_number, m.pattern_text.as_str()), (2, "!keep.log"));
2187 }
2188
2189 #[test]
2190 fn comments_blank_lines_and_trailing_spaces() {
2191 let list = IgnorePatternList::parse(b"\xef\xbb\xbf# comment\n\nfoo \nbar\\ \n\\#x\n", "");
2192 assert_eq!(list.len(), 3);
2193 assert!(ignored("foo \n", "foo", false));
2194 assert!(list.is_ignored("bar ", false, false).unwrap());
2195 assert!(list.is_ignored("#x", false, false).unwrap());
2196 }
2197
2198 #[test]
2199 fn base_directory_scopes_patterns() {
2200 let list = IgnorePatternList::parse(b"foo\n/bar\n", "sub");
2201 assert_eq!(list.is_ignored("sub/foo", false, false), Some(true));
2202 assert_eq!(list.is_ignored("sub/x/foo", false, false), Some(true));
2203 assert_eq!(list.is_ignored("sub/bar", false, false), Some(true));
2204 assert_eq!(list.is_ignored("sub/x/bar", false, false), None);
2205 assert_eq!(list.is_ignored("foo", false, false), None);
2206 assert_eq!(list.is_ignored("subfoo", false, false), None);
2207 }
2208
2209 #[test]
2210 fn ignore_case() {
2211 let list = IgnorePatternList::parse(b"*.LOG\nBuild/\n", "");
2212 assert_eq!(list.is_ignored("debug.log", false, false), None);
2213 assert_eq!(list.is_ignored("debug.log", false, true), Some(true));
2214 assert_eq!(list.is_ignored("build", true, true), Some(true));
2215 }
2216}
2217
2218#[cfg(test)]
2219mod dir_state_cache_tests {
2220 use super::*;
2221 use crate::repo::init_repository;
2222 use crate::RefStorageFormat;
2223 use tempfile::TempDir;
2224
2225 fn repo_with(files: &[(&str, &str)]) -> (TempDir, Repository) {
2226 let dir = TempDir::new().unwrap();
2227 let root = dir.path();
2228 init_repository(root, false, "main", None, RefStorageFormat::Files).unwrap();
2229 for (path, content) in files {
2230 let path = root.join(path);
2231 fs::create_dir_all(path.parent().unwrap()).unwrap();
2232 fs::write(path, content).unwrap();
2233 }
2234 let options = crate::environment::RepositoryOptions::empty().with_work_tree(root);
2235 let repo = Repository::open(&root.join(".git"), &options).unwrap();
2236 (dir, repo)
2237 }
2238
2239 #[test]
2240 fn each_directory_state_and_exclude_file_is_built_once() {
2241 let (_dir, repo) = repo_with(&[
2242 (".gitignore", "*.o\n"),
2243 ("a/x/.gitignore", "*.tmp\n"),
2244 ("a/y/.gitignore", "*.bak\n"),
2245 ]);
2246 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2247 for round in 0..3 {
2248 for (path, expected) in [
2249 ("a/x/f.tmp", true),
2250 ("a/y/f.tmp", false),
2251 ("a/y/f.bak", true),
2252 ("a/x/f.o", true),
2253 ("a/x/f.rs", false),
2254 ("top.o", true),
2255 ] {
2256 let (ignored, _) = matcher.check_path(&repo, None, path, false).unwrap();
2257 assert_eq!(ignored, expected, "round {round}: {path}");
2258 assert_eq!(
2259 matcher.is_path_ignored(&repo, None, path, false).unwrap(),
2260 expected
2261 );
2262 }
2263 }
2264 assert_eq!(matcher.dir_states.len(), 4);
2266 assert_eq!(matcher.rule_sets.len(), 4);
2267 let x = &matcher.dir_states[matcher.dir_state_ids["a/x"]];
2268 assert_eq!(x.stack.len(), 3);
2269 assert!(x.extendable && x.excluded.is_none());
2270 }
2271
2272 #[test]
2273 fn excluded_directory_is_recorded_on_its_descendants_states() {
2274 let (_dir, repo) = repo_with(&[(".gitignore", "build/\n!build/keep.txt\n")]);
2275 fs::create_dir_all(repo.work_tree().unwrap().join("build/deep")).unwrap();
2276 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2277 for path in ["build/keep.txt", "build/deep/a.c"] {
2278 let (ignored, matched) = matcher.check_path(&repo, None, path, false).unwrap();
2279 assert!(ignored, "{path}");
2280 assert_eq!(matched.unwrap().pattern_text, "build/");
2281 }
2282 assert!(matcher.dir_states[matcher.dir_state_ids["build"]]
2283 .excluded
2284 .is_some());
2285 assert!(matcher.dir_states[matcher.dir_state_ids["build/deep"]]
2286 .excluded
2287 .is_some());
2288 assert!(
2289 !matcher
2290 .check_path(&repo, None, "keep.txt", false)
2291 .unwrap()
2292 .0
2293 );
2294 }
2295
2296 #[test]
2297 fn a_file_ancestor_ends_the_per_directory_stack() {
2298 let (_dir, repo) = repo_with(&[(".gitignore", "*.o\n"), ("plain", "x\n")]);
2299 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2300 assert!(
2301 matcher
2302 .check_path(&repo, None, "plain/sub/x.o", false)
2303 .unwrap()
2304 .0
2305 );
2306 let plain = &matcher.dir_states[matcher.dir_state_ids["plain"]];
2307 assert_eq!(plain.stack.len(), 1);
2308 assert!(!plain.extendable);
2309 assert_eq!(
2310 matcher.dir_states[matcher.dir_state_ids["plain/sub"]]
2311 .stack
2312 .len(),
2313 1
2314 );
2315 assert!(!matcher.gitignore_cache.contains_key("plain"));
2316 }
2317
2318 #[test]
2319 fn batch_check_skips_directory_stats_only_when_the_path_stat_allows_it() {
2320 let (_dir, repo) = repo_with(&[
2321 (".gitignore", "*.o\n"),
2322 ("plain", "x\n"),
2323 ("real/.gitignore", "*.tmp\n"),
2324 ]);
2325 let index = Index::new();
2326 let paths = [
2327 "plain/sub/x.o",
2328 "plain/x.tmp",
2329 "real/x.tmp",
2330 "missing/deeper/x.tmp",
2331 "real/",
2332 ]
2333 .map(str::to_owned);
2334 let checks = repo.ignore_check(&paths, &index).unwrap();
2335 let ignored: Vec<bool> = checks.iter().map(|c| c.ignored).collect();
2336 assert_eq!(ignored, [true, false, true, false, false]);
2337 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2338 let wt = repo.work_tree().unwrap();
2339 let mut scratch = PathBuf::new();
2340 assert_eq!(
2341 probe_query_path(Some(wt), "real", &mut scratch),
2342 (true, true)
2343 );
2344 assert_eq!(
2345 probe_query_path(Some(wt), "missing/x", &mut scratch),
2346 (false, cfg!(unix))
2347 );
2348 assert_eq!(
2349 probe_query_path(Some(wt), "plain/x", &mut scratch),
2350 (false, false)
2351 );
2352 assert_eq!(
2353 probe_query_path(Some(wt), "real/", &mut scratch),
2354 (true, false)
2355 );
2356 matcher
2357 .check_path_with_ancestors(&repo, None, "plain/x.tmp", false, false)
2358 .unwrap();
2359 assert!(!matcher.dir_states[matcher.dir_state_ids["plain"]].extendable);
2360 }
2361
2362 #[test]
2363 fn adding_pattern_sources_refreshes_cached_directory_states() {
2364 let (_dir, repo) = repo_with(&[("d/.gitignore", "*.tmp\n")]);
2365 let mut matcher = IgnoreMatcher::from_repository(&repo).unwrap();
2366 assert!(!matcher
2367 .is_path_ignored(&repo, None, "d/x.log", false)
2368 .unwrap());
2369 assert!(!matcher
2370 .is_path_ignored(&repo, None, "d/sub/x.c", false)
2371 .unwrap());
2372 matcher.add_cli_excludes(&["*.log".to_owned(), "sub".to_owned()]);
2373 assert!(matcher
2374 .is_path_ignored(&repo, None, "d/x.log", false)
2375 .unwrap());
2376 assert!(matcher
2377 .is_path_ignored(&repo, None, "d/sub/x.c", false)
2378 .unwrap());
2379
2380 let mut folded = matcher.with_ignore_case(true);
2381 assert!(folded
2382 .is_path_ignored(&repo, None, "d/X.TMP", false)
2383 .unwrap());
2384
2385 folded.set_per_directory_name(".other");
2386 assert!(!folded
2387 .is_path_ignored(&repo, None, "d/x.tmp", false)
2388 .unwrap());
2389 }
2390
2391 #[test]
2392 fn last_match_in_picks_the_last_matching_line_for_the_base_directory() {
2393 let source = IgnoreSource::PerDirectory("sub/.gitignore".into());
2394 let rules =
2395 parse_gitignore_content("*.log\n!keep.log\nkeep*\n", source, "sub", &mut Vec::new())
2396 .unwrap();
2397 let hit = |path| last_match_in("sub", &rules, path, false, false).map(|r| r.line_number);
2398 assert_eq!(hit("sub/a.log"), Some(1));
2399 assert_eq!(hit("sub/deep/keep.log"), Some(3));
2400 assert_eq!(hit("sub/keep.txt"), Some(3));
2401 assert_eq!(hit("other/a.log"), None);
2402 assert_eq!(hit("sub"), None);
2403 assert_eq!(hit("subx/a.log"), None);
2404 assert_eq!(
2405 last_match_in("", &[], "a.log", false, false).map(|r| r.line_number),
2406 None
2407 );
2408 }
2409}