1use super::*;
6use crate::attributes::*;
7use crate::index_io::*;
8use crate::status::*;
9use crate::types_admin::*;
10use std::sync::atomic::{AtomicUsize, Ordering};
11
12pub fn untracked_paths(
13 worktree_root: impl AsRef<Path>,
14 git_dir: impl AsRef<Path>,
15 format: ObjectFormat,
16) -> Result<Vec<Vec<u8>>> {
17 untracked_paths_with_options(
18 worktree_root,
19 git_dir,
20 format,
21 UntrackedPathOptions::default(),
22 )
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct UntrackedPathspecFilter {
29 pub path: Vec<u8>,
30 pub recursive: bool,
31 pub is_glob: bool,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
35pub struct UntrackedPathOptions {
36 pub directory: bool,
37 pub no_empty_directory: bool,
38 pub preserve_ignored_directories: bool,
39 pub exclude_standard: bool,
40 pub ignored_only: bool,
41 pub exclude_patterns: Vec<Vec<u8>>,
42 pub exclude_per_directory: Vec<String>,
43 pub pathspecs: Vec<UntrackedPathspecFilter>,
44}
45
46pub use sley_pathspec::{
50 PathspecMatchMagic, WM_CASEFOLD, WM_PATHNAME, pathspec_is_glob, pathspec_item_matches,
51 wildmatch,
52};
53
54pub fn untracked_pathspec_matches(spec: &UntrackedPathspecFilter, path: &[u8]) -> bool {
56 if spec.path.is_empty() {
57 return true;
58 }
59 let path_no_slash = path.strip_suffix(b"/").unwrap_or(path);
60 if path == spec.path.as_slice() || path_no_slash == spec.path.as_slice() {
61 return true;
62 }
63 if spec.recursive
64 && let Some(rest) = path
65 .strip_prefix(spec.path.as_slice())
66 .and_then(|rest| rest.strip_prefix(b"/"))
67 && !rest.is_empty()
68 {
69 return true;
70 }
71 if spec.is_glob {
72 return untracked_wildmatch(&spec.path, path)
73 || untracked_wildmatch(&spec.path, path_no_slash);
74 }
75 false
76}
77
78pub fn untracked_pathspec_needs_descent(parent: &[u8], specs: &[UntrackedPathspecFilter]) -> bool {
80 if specs.is_empty() {
81 return false;
82 }
83 let parent_prefix = if parent.is_empty() {
84 Vec::new()
85 } else {
86 let mut prefix = parent.to_vec();
87 prefix.push(b'/');
88 prefix
89 };
90 for spec in specs {
91 if !parent.is_empty()
92 && spec.path.starts_with(&parent_prefix)
93 && spec.path.as_slice() != parent
94 {
95 return true;
96 }
97 if spec.is_glob && glob_pathspec_may_match_under(&spec.path, parent) {
98 return true;
99 }
100 if spec.recursive
101 && !parent.is_empty()
102 && parent.starts_with(spec.path.as_slice())
103 && parent != spec.path.as_slice()
104 {
105 return true;
106 }
107 }
108 false
109}
110
111pub(crate) fn untracked_pathspec_selects_directory(
118 specs: &[UntrackedPathspecFilter],
119 git_path: &[u8],
120) -> bool {
121 specs
122 .iter()
123 .any(|spec| untracked_pathspec_matches(spec, git_path))
124}
125
126pub(crate) fn glob_pathspec_may_match_under(pattern: &[u8], dir: &[u8]) -> bool {
127 let literal_prefix = literal_prefix_before_glob(pattern);
128 if literal_prefix.is_empty() {
129 return true;
130 }
131 if dir.is_empty() {
132 return true;
133 }
134 let mut dir_prefix = dir.to_vec();
135 dir_prefix.push(b'/');
136 if literal_prefix.starts_with(&dir_prefix) {
137 return true;
138 }
139 if dir_prefix.starts_with(&literal_prefix) {
140 return true;
141 }
142 literal_prefix
143 .strip_suffix(b"/")
144 .is_some_and(|prefix| prefix == dir)
145}
146
147pub(crate) fn literal_prefix_before_glob(pattern: &[u8]) -> Vec<u8> {
148 let mut prefix = Vec::new();
149 for &byte in pattern {
150 if matches!(byte, b'*' | b'?' | b'[') {
151 break;
152 }
153 prefix.push(byte);
154 }
155 prefix
156}
157
158pub(crate) fn insert_untracked_directory(paths: &mut BTreeSet<Vec<u8>>, git_path: &[u8]) {
159 let mut directory = git_path.to_vec();
160 if directory.last() != Some(&b'/') {
161 directory.push(b'/');
162 }
163 paths.insert(directory);
164}
165
166pub(crate) fn untracked_wildmatch(pattern: &[u8], text: &[u8]) -> bool {
168 wildmatch(pattern, text, 0)
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct IgnoreMatch {
175 pub source: Vec<u8>,
176 pub line_number: usize,
177 pub pattern: Vec<u8>,
178 pub ignored: bool,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum AttributeState {
183 Set,
184 Unset,
185 Value(Vec<u8>),
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct AttributeCheck {
190 pub attribute: Vec<u8>,
191 pub state: Option<AttributeState>,
192}
193
194pub fn untracked_paths_with_options(
195 worktree_root: impl AsRef<Path>,
196 git_dir: impl AsRef<Path>,
197 format: ObjectFormat,
198 options: UntrackedPathOptions,
199) -> Result<Vec<Vec<u8>>> {
200 let worktree_root = worktree_root.as_ref();
201 let git_dir = git_dir.as_ref();
202 let db = FileObjectDatabase::from_git_dir(git_dir, format);
203 let (index, stat_cache, _) = read_index_entries_with_stat_cache(git_dir, format, &db)?;
204 let all_index_paths = read_all_index_paths(git_dir, format)?;
205 let ignores = IgnoreMatcher::from_sources(
206 worktree_root,
207 options.exclude_standard,
208 &options.exclude_patterns,
209 &options.exclude_per_directory,
210 )?;
211 if options.ignored_only {
212 return ignored_untracked_paths(
213 worktree_root,
214 git_dir,
215 &index,
216 &ignores,
217 options.directory,
218 );
219 }
220 if options.directory {
221 let mut paths = BTreeSet::new();
222 collect_untracked_directory_paths(
223 worktree_root,
224 git_dir,
225 worktree_root,
226 &index,
227 &ignores,
228 &options,
229 &mut paths,
230 )?;
231 return Ok(paths.into_iter().collect());
232 }
233 let worktree = worktree_entries_with_stat_cache(
234 worktree_root,
235 git_dir,
236 format,
237 Some(&stat_cache),
238 None,
239 None,
240 )?;
241 Ok(ls_files_untracked_paths_from_worktree(
242 &worktree,
243 &index,
244 &all_index_paths,
245 &ignores,
246 ))
247}
248
249pub(crate) fn ls_files_untracked_paths_from_worktree(
253 worktree: &BTreeMap<Vec<u8>, TrackedEntry>,
254 index: &BTreeMap<Vec<u8>, TrackedEntry>,
255 all_index_paths: &BTreeSet<Vec<u8>>,
256 ignores: &IgnoreMatcher,
257) -> Vec<Vec<u8>> {
258 let mut paths = BTreeSet::new();
259 for (path, entry) in worktree {
260 if index.contains_key(path)
261 || all_index_paths.contains(path)
262 || ignores.is_ignored(path, false)
263 {
264 continue;
265 }
266 if entry.mode == 0o040000 && entry.oid.is_null() {
267 insert_untracked_directory(&mut paths, path);
268 continue;
269 }
270 paths.insert(path.clone());
271 }
272 paths.into_iter().collect()
273}
274
275pub fn path_matches_standard_ignore(
276 worktree_root: impl AsRef<Path>,
277 path: &[u8],
278 is_dir: bool,
279) -> Result<bool> {
280 path_matches_ignore(worktree_root, path, is_dir, true, &[])
281}
282
283pub fn standard_ignore_match(
284 worktree_root: impl AsRef<Path>,
285 path: &[u8],
286 is_dir: bool,
287) -> Result<Option<IgnoreMatch>> {
288 let ignores = IgnoreMatcher::from_worktree_root(worktree_root.as_ref())?;
289 Ok(ignores.match_for(path, is_dir).map(IgnorePattern::to_match))
290}
291
292pub fn standard_attributes_for_path(
293 worktree_root: impl AsRef<Path>,
294 path: &[u8],
295 requested: &[Vec<u8>],
296 all: bool,
297) -> Result<Vec<AttributeCheck>> {
298 let matcher = AttributeMatcher::from_worktree_root(worktree_root.as_ref())?;
299 Ok(matcher.attributes_for_path(path, requested, all))
300}
301
302pub struct StandardAttributeMatcher {
309 matcher: AttributeMatcher,
310}
311
312impl StandardAttributeMatcher {
313 pub fn from_worktree_root(worktree_root: impl AsRef<Path>) -> Result<Self> {
314 Ok(Self {
315 matcher: AttributeMatcher::from_worktree_root(worktree_root.as_ref())?,
316 })
317 }
318
319 pub fn attributes_for_path(
320 &self,
321 path: &[u8],
322 requested: &[Vec<u8>],
323 all: bool,
324 ) -> Vec<AttributeCheck> {
325 self.matcher.attributes_for_path(path, requested, all)
326 }
327}
328
329pub fn standard_attributes_for_path_in_repo(
330 attr_root: impl AsRef<Path>,
331 git_dir: impl AsRef<Path>,
332 path: &[u8],
333 requested: &[Vec<u8>],
334 all: bool,
335 include_worktree_attributes: bool,
336 ignore_case: bool,
337) -> Result<Vec<AttributeCheck>> {
338 let attr_root = attr_root.as_ref();
339 let git_dir = git_dir.as_ref();
340 let mut matcher = AttributeMatcher::default();
341 matcher.configure_case_sensitivity(git_dir);
342 matcher.ignore_case = ignore_case;
343 if !matcher.read_configured_attributes(attr_root, git_dir) {
344 matcher.read_default_global_attributes();
345 }
346 if include_worktree_attributes {
347 collect_attribute_patterns(attr_root, attr_root, &mut matcher)?;
348 }
349 read_attribute_patterns(
350 git_dir.join("info").join("attributes"),
351 &mut matcher,
352 &[],
353 b"info/attributes",
354 false,
355 );
356 Ok(matcher.attributes_for_path(path, requested, all))
357}
358
359pub fn standard_attributes_for_path_from_tree(
360 worktree_root: impl AsRef<Path>,
361 git_dir: impl AsRef<Path>,
362 db: &FileObjectDatabase,
363 format: ObjectFormat,
364 tree_oid: &ObjectId,
365 path: &[u8],
366 requested: &[Vec<u8>],
367 all: bool,
368) -> Result<Vec<AttributeCheck>> {
369 let mut matcher = AttributeMatcher::default();
370 let worktree_root = worktree_root.as_ref();
371 let git_dir = git_dir.as_ref();
372 matcher.configure_case_sensitivity(git_dir);
373 if !matcher.read_configured_attributes(worktree_root, git_dir) {
374 matcher.read_default_global_attributes();
375 }
376 collect_attribute_patterns_from_tree(db, format, tree_oid, Vec::new(), &mut matcher)?;
377 read_attribute_patterns(
378 git_dir.join("info").join("attributes"),
379 &mut matcher,
380 &[],
381 b"info/attributes",
382 false,
383 );
384 Ok(matcher.attributes_for_path(path, requested, all))
385}
386
387pub fn standard_attributes_for_path_from_index(
388 worktree_root: impl AsRef<Path>,
389 git_dir: impl AsRef<Path>,
390 format: ObjectFormat,
391 path: &[u8],
392 requested: &[Vec<u8>],
393 all: bool,
394) -> Result<Vec<AttributeCheck>> {
395 let worktree_root = worktree_root.as_ref();
396 let git_dir = git_dir.as_ref();
397 let mut matcher = AttributeMatcher::default();
398 matcher.configure_case_sensitivity(git_dir);
399 if !matcher.read_configured_attributes(worktree_root, git_dir) {
400 matcher.read_default_global_attributes();
401 }
402 let db = FileObjectDatabase::from_git_dir(git_dir, format);
403 collect_attribute_patterns_from_index(git_dir, format, &db, &mut matcher)?;
404 read_attribute_patterns(
405 git_dir.join("info").join("attributes"),
406 &mut matcher,
407 &[],
408 b"info/attributes",
409 false,
410 );
411 Ok(matcher.attributes_for_path(path, requested, all))
412}
413
414pub fn path_matches_ignore(
415 worktree_root: impl AsRef<Path>,
416 path: &[u8],
417 is_dir: bool,
418 exclude_standard: bool,
419 exclude_patterns: &[Vec<u8>],
420) -> Result<bool> {
421 path_matches_ignore_with_per_directory(
422 worktree_root,
423 path,
424 is_dir,
425 exclude_standard,
426 exclude_patterns,
427 &[],
428 )
429}
430
431pub fn path_matches_ignore_with_per_directory(
432 worktree_root: impl AsRef<Path>,
433 path: &[u8],
434 is_dir: bool,
435 exclude_standard: bool,
436 exclude_patterns: &[Vec<u8>],
437 exclude_per_directory: &[String],
438) -> Result<bool> {
439 let ignores = IgnoreMatcher::from_sources(
440 worktree_root.as_ref(),
441 exclude_standard,
442 exclude_patterns,
443 exclude_per_directory,
444 )?;
445 Ok(ignores.is_ignored(path, is_dir))
446}
447
448pub fn ignored_index_entries<'a>(
449 worktree_root: impl AsRef<Path>,
450 entries: &'a [IndexEntry],
451 exclude_standard: bool,
452 exclude_patterns: &[Vec<u8>],
453 exclude_per_directory: &[String],
454) -> Result<Vec<&'a IndexEntry>> {
455 let ignores = IgnoreMatcher::from_sources(
456 worktree_root.as_ref(),
457 exclude_standard,
458 exclude_patterns,
459 exclude_per_directory,
460 )?;
461 Ok(entries
462 .iter()
463 .filter(|entry| ignores.is_ignored(entry.path.as_bytes(), false))
464 .collect())
465}
466
467pub(crate) fn collect_untracked_directory_paths(
468 root: &Path,
469 git_dir: &Path,
470 dir: &Path,
471 index: &BTreeMap<Vec<u8>, TrackedEntry>,
472 ignores: &IgnoreMatcher,
473 options: &UntrackedPathOptions,
474 paths: &mut BTreeSet<Vec<u8>>,
475) -> Result<()> {
476 if is_same_path(dir, git_dir) {
477 return Ok(());
478 }
479 let mut entries = fs::read_dir(dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
480 entries.sort_by_key(|entry| entry.file_name());
481 for entry in entries {
482 let path = entry.path();
483 if is_dot_git_entry(&path) {
484 continue;
485 }
486 if is_embedded_git_internals(root, &path) {
487 continue;
488 }
489 if is_same_path(&path, git_dir) {
490 continue;
491 }
492 let metadata = entry.metadata()?;
493 let relative = path.strip_prefix(root).map_err(|_| {
494 GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
495 })?;
496 let git_path = git_path_bytes(relative)?;
497 if index
498 .get(&git_path)
499 .is_some_and(|entry| sley_index::is_gitlink(entry.mode))
500 {
501 continue;
502 }
503 if ignores.is_ignored(&git_path, metadata.is_dir()) {
504 continue;
505 }
506 if metadata.is_dir() {
507 if is_nested_repository_boundary(&path, git_dir) {
508 insert_untracked_directory(paths, &git_path);
509 continue;
510 }
511 let has_tracked_below = index_has_path_under(index, &git_path);
512 let needs_descent = untracked_pathspec_needs_descent(&git_path, &options.pathspecs);
513 if has_tracked_below {
514 collect_untracked_directory_paths(
515 root, git_dir, &path, index, ignores, options, paths,
516 )?;
517 } else if active_repository_worktree_dir(&path, git_dir) {
518 insert_untracked_directory(paths, &git_path);
519 } else if needs_descent {
520 if untracked_pathspec_selects_directory(&options.pathspecs, &git_path) {
528 insert_untracked_directory(paths, &git_path);
529 continue;
530 }
531 collect_untracked_directory_paths(
532 root, git_dir, &path, index, ignores, options, paths,
533 )?;
534 } else if options.preserve_ignored_directories
535 && directory_has_ignored(&path, root, git_dir, ignores)?
536 {
537 collect_untracked_directory_paths(
538 root, git_dir, &path, index, ignores, options, paths,
539 )?;
540 } else if !options.no_empty_directory
541 || directory_has_file(&path, root, git_dir, ignores)?
542 {
543 insert_untracked_directory(paths, &git_path);
544 }
545 } else if !index.contains_key(&git_path)
546 && (metadata.is_file() || metadata.file_type().is_symlink())
547 && (options.pathspecs.is_empty()
548 || options
549 .pathspecs
550 .iter()
551 .any(|spec| untracked_pathspec_matches(spec, &git_path)))
552 {
553 paths.insert(git_path);
561 }
562 }
563 Ok(())
564}
565
566pub(crate) fn index_has_path_under(
567 index: &BTreeMap<Vec<u8>, TrackedEntry>,
568 directory: &[u8],
569) -> bool {
570 let mut prefix = directory.to_vec();
574 prefix.push(b'/');
575 index
576 .range::<[u8], _>((
577 std::ops::Bound::Included(prefix.as_slice()),
578 std::ops::Bound::Unbounded,
579 ))
580 .next()
581 .is_some_and(|(path, _)| path.starts_with(&prefix))
582}
583
584pub(crate) fn normal_untracked_paths_from_worktree(
587 worktree: &BTreeMap<Vec<u8>, TrackedEntry>,
588 index: &BTreeMap<Vec<u8>, TrackedEntry>,
589 ignores: &IgnoreMatcher,
590) -> Vec<Vec<u8>> {
591 let mut paths = BTreeSet::new();
592 for (path, entry) in worktree {
593 if index.contains_key(path) || path_or_parent_is_ignored(ignores, path, false) {
594 continue;
595 }
596 if entry.mode == 0o040000 && entry.oid.is_null() {
597 insert_untracked_directory(&mut paths, path);
598 continue;
599 }
600 paths.insert(untracked_normal_rollup_path(path, index, ignores));
601 }
602 paths.into_iter().collect()
603}
604
605pub(crate) fn path_or_parent_is_ignored(
606 ignores: &IgnoreMatcher,
607 path: &[u8],
608 is_dir: bool,
609) -> bool {
610 if ignores.is_ignored(path, is_dir) {
611 return true;
612 }
613 for (index, byte) in path.iter().enumerate() {
614 if *byte == b'/' && index > 0 && ignores.is_ignored(&path[..index], true) {
615 return true;
616 }
617 }
618 false
619}
620
621pub(crate) fn status_untracked_paths_from_index(
622 root: &Path,
623 git_dir: &Path,
624 index: &Index,
625 stat_cache: &IndexStatCache,
626 ignores: &mut IgnoreMatcher,
627 untracked_mode: StatusUntrackedMode,
628 profile: Option<&mut StatusProfileCounters>,
629) -> Result<Vec<Vec<u8>>> {
630 if matches!(untracked_mode, StatusUntrackedMode::None) {
631 return Ok(Vec::new());
632 }
633 let mut paths = Vec::new();
634 let tracked_dirs = stage0_tracked_directories(index);
635 let tracked = IndexStatusLookup {
636 stat_cache,
637 tracked_dirs: &tracked_dirs,
638 };
639 let mut context = StatusUntrackedWalk {
640 git_dir,
641 tracked: &tracked,
642 ignores,
643 untracked_mode,
644 profile,
645 };
646 collect_status_untracked_paths(&mut context, root, &[], &mut paths)?;
647 paths.sort();
648 paths.dedup();
649 Ok(paths)
650}
651
652pub(crate) fn status_untracked_paths_from_borrowed_index(
653 root: &Path,
654 git_dir: &Path,
655 index: &BorrowedIndex<'_>,
656 ignores: &mut IgnoreMatcher,
657 untracked_mode: StatusUntrackedMode,
658 profile: Option<&mut StatusProfileCounters>,
659) -> Result<Vec<Vec<u8>>> {
660 if matches!(untracked_mode, StatusUntrackedMode::None) {
661 return Ok(Vec::new());
662 }
663 let (mut paths, local_profile) = collect_status_untracked_paths_from_borrowed_index_parallel(
664 root,
665 git_dir,
666 index,
667 ignores.clone(),
668 untracked_mode,
669 )?;
670 if let Some(profile) = profile {
671 profile.merge_untracked(local_profile);
672 }
673 paths.sort();
674 paths.dedup();
675 Ok(paths)
676}
677
678pub(crate) fn stream_status_untracked_paths_from_borrowed_index<F>(
679 root: &Path,
680 git_dir: &Path,
681 index: &BorrowedIndex<'_>,
682 ignores: &mut IgnoreMatcher,
683 untracked_mode: StatusUntrackedMode,
684 profile: Option<&mut StatusProfileCounters>,
685 mut emit: F,
686) -> Result<()>
687where
688 F: for<'a> FnMut(&'a [u8]) -> Result<StreamControl>,
689{
690 if matches!(untracked_mode, StatusUntrackedMode::None) {
691 return Ok(());
692 }
693 let tracked = BorrowedIndexLookup::new(&index.entries);
694 let mut context = StatusUntrackedWalk {
695 git_dir,
696 tracked: &tracked,
697 ignores,
698 untracked_mode,
699 profile,
700 };
701 stream_status_untracked_paths(&mut context, root, &[], &mut emit).map(|_| ())
702}
703
704pub(crate) fn status_untracked_count_from_borrowed_index(
705 root: &Path,
706 git_dir: &Path,
707 index: &BorrowedIndex<'_>,
708 ignores: &mut IgnoreMatcher,
709 untracked_mode: StatusUntrackedMode,
710 profile: Option<&mut StatusProfileCounters>,
711) -> Result<usize> {
712 if matches!(untracked_mode, StatusUntrackedMode::None) {
713 return Ok(0);
714 }
715 let (paths, local_profile) = collect_status_untracked_paths_from_borrowed_index_parallel(
716 root,
717 git_dir,
718 index,
719 ignores.clone(),
720 untracked_mode,
721 )?;
722 if let Some(profile) = profile {
723 profile.merge_untracked(local_profile);
724 }
725 Ok(paths.len())
726}
727
728pub(crate) fn collect_status_untracked_paths_from_borrowed_index_parallel(
729 root: &Path,
730 git_dir: &Path,
731 index: &BorrowedIndex<'_>,
732 ignores: IgnoreMatcher,
733 untracked_mode: StatusUntrackedMode,
734) -> Result<(Vec<Vec<u8>>, StatusProfileCounters)> {
735 let executor = StatusExecutor::new();
736 let mut frontier = vec![StatusUntrackedFrontierTask {
737 dir: root.to_path_buf(),
738 git_path: Vec::new(),
739 ignores,
740 }];
741 let mut paths = Vec::new();
742 let mut profile = StatusProfileCounters::default();
743
744 while !frontier.is_empty() {
745 let worker_count = executor.worker_count_for(frontier.len(), 1, 8);
746 let output = if worker_count <= 1 {
747 let tracked = BorrowedIndexLookup::new(&index.entries);
748 let mut output = StatusUntrackedFrontierOutput::default();
749 for mut task in frontier {
750 let mut context = StatusUntrackedWalk {
751 git_dir,
752 tracked: &tracked,
753 ignores: &mut task.ignores,
754 untracked_mode,
755 profile: Some(&mut output.profile),
756 };
757 collect_status_untracked_frontier_dir(
758 &mut context,
759 &task.dir,
760 &task.git_path,
761 &mut output.paths,
762 &mut output.next,
763 )?;
764 }
765 output
766 } else {
767 let next_task = AtomicUsize::new(0);
768 std::thread::scope(|scope| -> Result<StatusUntrackedFrontierOutput> {
769 let mut handles = Vec::new();
770 for _ in 0..worker_count {
771 let frontier = &frontier;
772 let next_task = &next_task;
773 handles.push(executor.spawn(
774 scope,
775 "status-untracked-frontier",
776 move || -> Result<StatusUntrackedFrontierOutput> {
777 let tracked = BorrowedIndexLookup::new(&index.entries);
778 let mut output = StatusUntrackedFrontierOutput::default();
779 loop {
780 let task_idx = next_task.fetch_add(1, Ordering::Relaxed);
781 let Some(mut task) = frontier.get(task_idx).cloned() else {
782 break;
783 };
784 let mut context = StatusUntrackedWalk {
785 git_dir,
786 tracked: &tracked,
787 ignores: &mut task.ignores,
788 untracked_mode,
789 profile: Some(&mut output.profile),
790 };
791 collect_status_untracked_frontier_dir(
792 &mut context,
793 &task.dir,
794 &task.git_path,
795 &mut output.paths,
796 &mut output.next,
797 )?;
798 }
799 Ok(output)
800 },
801 )?);
802 }
803
804 let mut combined = StatusUntrackedFrontierOutput::default();
805 for handle in handles {
806 let mut output = handle.join()?;
807 combined.paths.append(&mut output.paths);
808 combined.next.append(&mut output.next);
809 combined.profile.merge_untracked(output.profile);
810 }
811 Ok(combined)
812 })?
813 };
814
815 let mut output = output;
816 paths.append(&mut output.paths);
817 profile.merge_untracked(output.profile);
818 frontier = output.next;
819 }
820
821 Ok((paths, profile))
822}
823
824pub(crate) trait StatusTrackedLookup {
825 fn tracked_kind(&self, git_path: &[u8]) -> Option<StatusTrackedKind>;
826 fn tracked_directory_kind(&self, git_path: &[u8]) -> Option<StatusTrackedDirectoryKind>;
827}
828
829#[derive(Debug, Clone, Copy, PartialEq, Eq)]
830pub(crate) enum StatusTrackedKind {
831 File,
832 Gitlink,
833 SkipWorktree,
834}
835
836impl StatusTrackedKind {
837 fn from_mode_and_skip(mode: u32, skip_worktree: bool) -> Self {
838 if sley_index::is_gitlink(mode) {
839 Self::Gitlink
840 } else if skip_worktree {
841 Self::SkipWorktree
842 } else {
843 Self::File
844 }
845 }
846}
847
848#[derive(Debug, Clone, Copy, PartialEq, Eq)]
849pub(crate) enum StatusTrackedDirectoryKind {
850 ContainsTracked,
851 TrackedExcluded,
852}
853
854pub(crate) struct IndexStatusLookup<'a> {
855 stat_cache: &'a IndexStatCache,
856 tracked_dirs: &'a HashSet<&'a [u8]>,
857}
858
859impl StatusTrackedLookup for IndexStatusLookup<'_> {
860 fn tracked_kind(&self, git_path: &[u8]) -> Option<StatusTrackedKind> {
861 self.stat_cache.entries.get(git_path).map(|entry| {
862 StatusTrackedKind::from_mode_and_skip(entry.mode, entry.is_skip_worktree())
863 })
864 }
865
866 fn tracked_directory_kind(&self, git_path: &[u8]) -> Option<StatusTrackedDirectoryKind> {
867 self.tracked_dirs
868 .contains(git_path)
869 .then_some(StatusTrackedDirectoryKind::ContainsTracked)
870 }
871}
872
873pub(crate) struct BorrowedIndexLookup<'a> {
874 entries: &'a [IndexEntryRef<'a>],
875 exact_cursor: Cell<usize>,
876 directory_prefix: RefCell<Vec<u8>>,
877}
878
879impl<'a> BorrowedIndexLookup<'a> {
880 pub(crate) fn new(entries: &'a [IndexEntryRef<'a>]) -> Self {
881 Self {
882 entries,
883 exact_cursor: Cell::new(0),
884 directory_prefix: RefCell::new(Vec::new()),
885 }
886 }
887}
888
889impl StatusTrackedLookup for BorrowedIndexLookup<'_> {
890 fn tracked_kind(&self, git_path: &[u8]) -> Option<StatusTrackedKind> {
891 let mut start = self.exact_cursor.get().min(self.entries.len());
892 if start == self.entries.len() || self.entries[start].path > git_path {
893 start = self.entries.partition_point(|entry| entry.path < git_path);
894 } else {
895 while start < self.entries.len() && self.entries[start].path < git_path {
896 start += 1;
897 }
898 }
899 self.exact_cursor.set(start);
900 self.entries[start..]
901 .iter()
902 .take_while(|entry| entry.path == git_path)
903 .find(|entry| entry.stage() == Stage::Normal)
904 .map(|entry| {
905 StatusTrackedKind::from_mode_and_skip(entry.mode, entry.is_skip_worktree())
906 })
907 }
908
909 fn tracked_directory_kind(&self, git_path: &[u8]) -> Option<StatusTrackedDirectoryKind> {
910 let mut prefix_buf = self.directory_prefix.borrow_mut();
911 prefix_buf.clear();
912 prefix_buf.extend_from_slice(git_path);
913 prefix_buf.push(b'/');
914 let prefix = prefix_buf.as_slice();
915 let start = self.entries.partition_point(|entry| entry.path < prefix);
916 let mut saw_normal = false;
917 for entry in self.entries[start..]
918 .iter()
919 .take_while(|entry| entry.path.starts_with(prefix))
920 {
921 if entry.stage() != Stage::Normal {
922 continue;
923 }
924 saw_normal = true;
925 if !entry.is_skip_worktree() {
926 return Some(StatusTrackedDirectoryKind::ContainsTracked);
927 }
928 }
929 saw_normal.then_some(StatusTrackedDirectoryKind::TrackedExcluded)
930 }
931}
932
933pub(crate) struct StatusUntrackedWalk<'a, T: StatusTrackedLookup + ?Sized> {
934 git_dir: &'a Path,
935 tracked: &'a T,
936 ignores: &'a mut IgnoreMatcher,
937 untracked_mode: StatusUntrackedMode,
938 profile: Option<&'a mut StatusProfileCounters>,
939}
940
941#[derive(Clone)]
942pub(crate) struct StatusUntrackedFrontierTask {
943 dir: PathBuf,
944 git_path: Vec<u8>,
945 ignores: IgnoreMatcher,
946}
947
948#[derive(Default)]
949pub(crate) struct StatusUntrackedFrontierOutput {
950 paths: Vec<Vec<u8>>,
951 next: Vec<StatusUntrackedFrontierTask>,
952 profile: StatusProfileCounters,
953}
954
955pub(crate) fn collect_status_untracked_paths<T: StatusTrackedLookup + ?Sized>(
956 context: &mut StatusUntrackedWalk<'_, T>,
957 dir: &Path,
958 dir_git_path: &[u8],
959 paths: &mut Vec<Vec<u8>>,
960) -> Result<()> {
961 if is_same_path(dir, context.git_dir) {
962 return Ok(());
963 }
964 let ignore_len = context.ignores.patterns.len();
965 let mut entries = read_dir_entries_with_ignore_patterns(
966 dir,
967 dir_git_path,
968 context.ignores,
969 context.profile.as_deref_mut(),
970 )?;
971 entries.sort_by_key(|entry| entry.file_name());
972 let result = (|| -> Result<()> {
973 let mut git_path = dir_git_path.to_vec();
974 for entry in entries {
975 let file_name = entry.file_name();
976 if file_name == std::ffi::OsStr::new(".git") {
977 continue;
978 }
979 let path_len = git_path_push_component(&mut git_path, &file_name);
980 let entry_result = (|| -> Result<()> {
981 if let Some(tracked_kind) = context.tracked.tracked_kind(&git_path) {
982 if let Some(profile) = context.profile.as_deref_mut() {
983 profile.tracked_exact_hits += 1;
984 }
985 if !matches!(context.untracked_mode, StatusUntrackedMode::All)
986 || tracked_kind == StatusTrackedKind::Gitlink
987 {
988 return Ok(());
989 }
990 if let Some(profile) = context.profile.as_deref_mut() {
991 profile.file_type_calls += 1;
992 }
993 let file_type = entry.file_type()?;
994 if file_type.is_dir() {
995 let path = entry.path();
996 if !is_same_path(&path, context.git_dir) {
997 collect_status_untracked_paths(context, &path, &git_path, paths)?;
998 }
999 }
1000 return Ok(());
1001 }
1002 if let Some(profile) = context.profile.as_deref_mut() {
1003 profile.file_type_calls += 1;
1004 }
1005 let file_type = entry.file_type()?;
1006 let is_dir = file_type.is_dir();
1007 if file_type.is_file() || file_type.is_symlink() {
1008 if !context.ignores.is_ignored_profiled(
1009 &git_path,
1010 false,
1011 context.profile.as_deref_mut(),
1012 ) {
1013 paths.push(git_path.clone());
1014 }
1015 return Ok(());
1016 } else if is_dir {
1017 let path = entry.path();
1018 if context.ignores.is_ignored_profiled(
1019 &git_path,
1020 true,
1021 context.profile.as_deref_mut(),
1022 ) {
1023 return Ok(());
1024 }
1025 if is_same_path(&path, context.git_dir) {
1026 return Ok(());
1027 }
1028 let tracked_directory = context.tracked.tracked_directory_kind(&git_path);
1029 if let Some(directory_kind) = tracked_directory {
1030 if let Some(profile) = context.profile.as_deref_mut() {
1031 profile.tracked_dir_prefix_hits += 1;
1032 if directory_kind == StatusTrackedDirectoryKind::TrackedExcluded {
1033 profile.tracked_skip_worktree_prefix_hits += 1;
1034 }
1035 }
1036 }
1037 match context.untracked_mode {
1038 StatusUntrackedMode::All => {
1039 if tracked_directory.is_none()
1040 && is_nested_repository_boundary(&path, context.git_dir)
1041 {
1042 push_untracked_directory(paths, &git_path);
1043 } else {
1044 collect_status_untracked_paths(context, &path, &git_path, paths)?;
1045 }
1046 }
1047 StatusUntrackedMode::Normal => {
1048 if tracked_directory.is_some() {
1049 collect_status_untracked_paths(context, &path, &git_path, paths)?;
1050 } else if is_nested_repository_boundary(&path, context.git_dir) {
1051 push_untracked_directory(paths, &git_path);
1052 } else if status_untracked_directory_has_file(
1053 context, &path, &git_path,
1054 )? {
1055 push_untracked_directory(paths, &git_path);
1056 }
1057 }
1058 StatusUntrackedMode::None => {}
1059 }
1060 }
1061 Ok(())
1062 })();
1063 git_path.truncate(path_len);
1064 entry_result?;
1065 }
1066 Ok(())
1067 })();
1068 context.ignores.truncate(ignore_len);
1069 result
1070}
1071
1072pub(crate) fn collect_status_untracked_frontier_dir<T: StatusTrackedLookup + ?Sized>(
1073 context: &mut StatusUntrackedWalk<'_, T>,
1074 dir: &Path,
1075 dir_git_path: &[u8],
1076 paths: &mut Vec<Vec<u8>>,
1077 next: &mut Vec<StatusUntrackedFrontierTask>,
1078) -> Result<()> {
1079 if is_same_path(dir, context.git_dir) {
1080 return Ok(());
1081 }
1082 let mut entries = read_dir_entries_with_ignore_patterns(
1083 dir,
1084 dir_git_path,
1085 context.ignores,
1086 context.profile.as_deref_mut(),
1087 )?;
1088 entries.sort_by_key(|entry| entry.file_name());
1089 let mut git_path = dir_git_path.to_vec();
1090 for entry in entries {
1091 let file_name = entry.file_name();
1092 if file_name == std::ffi::OsStr::new(".git") {
1093 continue;
1094 }
1095 let path_len = git_path_push_component(&mut git_path, &file_name);
1096 let entry_result = (|| -> Result<()> {
1097 if let Some(tracked_kind) = context.tracked.tracked_kind(&git_path) {
1098 if let Some(profile) = context.profile.as_deref_mut() {
1099 profile.tracked_exact_hits += 1;
1100 }
1101 if !matches!(context.untracked_mode, StatusUntrackedMode::All)
1102 || tracked_kind == StatusTrackedKind::Gitlink
1103 {
1104 return Ok(());
1105 }
1106 if let Some(profile) = context.profile.as_deref_mut() {
1107 profile.file_type_calls += 1;
1108 }
1109 let file_type = entry.file_type()?;
1110 if file_type.is_dir() {
1111 let path = entry.path();
1112 if !is_same_path(&path, context.git_dir) {
1113 next.push(StatusUntrackedFrontierTask {
1114 dir: path,
1115 git_path: git_path.clone(),
1116 ignores: context.ignores.clone(),
1117 });
1118 }
1119 }
1120 return Ok(());
1121 }
1122 if let Some(profile) = context.profile.as_deref_mut() {
1123 profile.file_type_calls += 1;
1124 }
1125 let file_type = entry.file_type()?;
1126 let is_dir = file_type.is_dir();
1127 if file_type.is_file() || file_type.is_symlink() {
1128 if !context.ignores.is_ignored_profiled(
1129 &git_path,
1130 false,
1131 context.profile.as_deref_mut(),
1132 ) {
1133 paths.push(git_path.clone());
1134 }
1135 return Ok(());
1136 } else if is_dir {
1137 let path = entry.path();
1138 if context.ignores.is_ignored_profiled(
1139 &git_path,
1140 true,
1141 context.profile.as_deref_mut(),
1142 ) {
1143 return Ok(());
1144 }
1145 if is_same_path(&path, context.git_dir) {
1146 return Ok(());
1147 }
1148 let tracked_directory = context.tracked.tracked_directory_kind(&git_path);
1149 if let Some(directory_kind) = tracked_directory {
1150 if let Some(profile) = context.profile.as_deref_mut() {
1151 profile.tracked_dir_prefix_hits += 1;
1152 if directory_kind == StatusTrackedDirectoryKind::TrackedExcluded {
1153 profile.tracked_skip_worktree_prefix_hits += 1;
1154 }
1155 }
1156 }
1157 match context.untracked_mode {
1158 StatusUntrackedMode::All => {
1159 if tracked_directory.is_none()
1160 && is_nested_repository_boundary(&path, context.git_dir)
1161 {
1162 push_untracked_directory(paths, &git_path);
1163 } else {
1164 next.push(StatusUntrackedFrontierTask {
1165 dir: path,
1166 git_path: git_path.clone(),
1167 ignores: context.ignores.clone(),
1168 });
1169 }
1170 }
1171 StatusUntrackedMode::Normal => {
1172 if tracked_directory.is_some() {
1173 next.push(StatusUntrackedFrontierTask {
1174 dir: path,
1175 git_path: git_path.clone(),
1176 ignores: context.ignores.clone(),
1177 });
1178 } else if is_nested_repository_boundary(&path, context.git_dir)
1179 || status_untracked_directory_has_file(context, &path, &git_path)?
1180 {
1181 push_untracked_directory(paths, &git_path);
1182 }
1183 }
1184 StatusUntrackedMode::None => {}
1185 }
1186 }
1187 Ok(())
1188 })();
1189 git_path.truncate(path_len);
1190 entry_result?;
1191 }
1192 Ok(())
1193}
1194
1195pub(crate) fn stream_status_untracked_paths<T, F>(
1196 context: &mut StatusUntrackedWalk<'_, T>,
1197 dir: &Path,
1198 dir_git_path: &[u8],
1199 emit: &mut F,
1200) -> Result<StreamControl>
1201where
1202 T: StatusTrackedLookup + ?Sized,
1203 F: for<'a> FnMut(&'a [u8]) -> Result<StreamControl>,
1204{
1205 if is_same_path(dir, context.git_dir) {
1206 return Ok(StreamControl::Continue);
1207 }
1208 let ignore_len = context.ignores.patterns.len();
1209 let mut entries = read_dir_entries_with_ignore_patterns(
1210 dir,
1211 dir_git_path,
1212 context.ignores,
1213 context.profile.as_deref_mut(),
1214 )?;
1215 entries.sort_by_key(|entry| entry.file_name());
1216 let result = (|| -> Result<StreamControl> {
1217 let mut git_path = dir_git_path.to_vec();
1218 for entry in entries {
1219 let file_name = entry.file_name();
1220 if file_name == std::ffi::OsStr::new(".git") {
1221 continue;
1222 }
1223 let path_len = git_path_push_component(&mut git_path, &file_name);
1224 let entry_result = (|| -> Result<StreamControl> {
1225 if let Some(tracked_kind) = context.tracked.tracked_kind(&git_path) {
1226 if let Some(profile) = context.profile.as_deref_mut() {
1227 profile.tracked_exact_hits += 1;
1228 }
1229 if !matches!(context.untracked_mode, StatusUntrackedMode::All)
1230 || tracked_kind == StatusTrackedKind::Gitlink
1231 {
1232 return Ok(StreamControl::Continue);
1233 }
1234 if let Some(profile) = context.profile.as_deref_mut() {
1235 profile.file_type_calls += 1;
1236 }
1237 let file_type = entry.file_type()?;
1238 if file_type.is_dir() {
1239 let path = entry.path();
1240 if !is_same_path(&path, context.git_dir) {
1241 if stream_status_untracked_paths(context, &path, &git_path, emit)?
1242 .is_stop()
1243 {
1244 return Ok(StreamControl::Stop);
1245 }
1246 }
1247 }
1248 return Ok(StreamControl::Continue);
1249 }
1250 if let Some(profile) = context.profile.as_deref_mut() {
1251 profile.file_type_calls += 1;
1252 }
1253 let file_type = entry.file_type()?;
1254 let is_dir = file_type.is_dir();
1255 if file_type.is_file() || file_type.is_symlink() {
1256 if !context.ignores.is_ignored_profiled(
1257 &git_path,
1258 false,
1259 context.profile.as_deref_mut(),
1260 ) {
1261 if emit_status_untracked_path(context, &git_path, emit)?.is_stop() {
1262 return Ok(StreamControl::Stop);
1263 }
1264 }
1265 return Ok(StreamControl::Continue);
1266 } else if is_dir {
1267 if context.ignores.is_ignored_profiled(
1268 &git_path,
1269 true,
1270 context.profile.as_deref_mut(),
1271 ) {
1272 return Ok(StreamControl::Continue);
1273 }
1274 let path = entry.path();
1275 if is_same_path(&path, context.git_dir) {
1276 return Ok(StreamControl::Continue);
1277 }
1278 let tracked_directory = context.tracked.tracked_directory_kind(&git_path);
1279 if let Some(directory_kind) = tracked_directory {
1280 if let Some(profile) = context.profile.as_deref_mut() {
1281 profile.tracked_dir_prefix_hits += 1;
1282 if directory_kind == StatusTrackedDirectoryKind::TrackedExcluded {
1283 profile.tracked_skip_worktree_prefix_hits += 1;
1284 }
1285 }
1286 }
1287 match context.untracked_mode {
1288 StatusUntrackedMode::All => {
1289 if tracked_directory.is_none()
1290 && is_nested_repository_boundary(&path, context.git_dir)
1291 {
1292 let directory_len = git_path.len();
1293 if git_path.last() != Some(&b'/') {
1294 git_path.push(b'/');
1295 }
1296 let control = emit_status_untracked_path(context, &git_path, emit)?;
1297 git_path.truncate(directory_len);
1298 if control.is_stop() {
1299 return Ok(StreamControl::Stop);
1300 }
1301 } else {
1302 if stream_status_untracked_paths(context, &path, &git_path, emit)?
1303 .is_stop()
1304 {
1305 return Ok(StreamControl::Stop);
1306 }
1307 }
1308 }
1309 StatusUntrackedMode::Normal => {
1310 if tracked_directory.is_some() {
1311 if stream_status_untracked_paths(context, &path, &git_path, emit)?
1312 .is_stop()
1313 {
1314 return Ok(StreamControl::Stop);
1315 }
1316 } else if is_nested_repository_boundary(&path, context.git_dir)
1317 || status_untracked_directory_has_file(context, &path, &git_path)?
1318 {
1319 let directory_len = git_path.len();
1320 if git_path.last() != Some(&b'/') {
1321 git_path.push(b'/');
1322 }
1323 let control = emit_status_untracked_path(context, &git_path, emit)?;
1324 git_path.truncate(directory_len);
1325 if control.is_stop() {
1326 return Ok(StreamControl::Stop);
1327 }
1328 }
1329 }
1330 StatusUntrackedMode::None => {}
1331 }
1332 }
1333 Ok(StreamControl::Continue)
1334 })();
1335 git_path.truncate(path_len);
1336 if entry_result?.is_stop() {
1337 return Ok(StreamControl::Stop);
1338 }
1339 }
1340 Ok(StreamControl::Continue)
1341 })();
1342 context.ignores.truncate(ignore_len);
1343 result
1344}
1345
1346pub(crate) fn emit_status_untracked_path<T, F>(
1347 context: &mut StatusUntrackedWalk<'_, T>,
1348 path: &[u8],
1349 emit: &mut F,
1350) -> Result<StreamControl>
1351where
1352 T: StatusTrackedLookup + ?Sized,
1353 F: for<'a> FnMut(&'a [u8]) -> Result<StreamControl>,
1354{
1355 if let Some(profile) = context.profile.as_deref_mut() {
1356 profile.untracked_rows += 1;
1357 }
1358 emit(path)
1359}
1360
1361pub(crate) fn stage0_tracked_directories(index: &Index) -> HashSet<&[u8]> {
1362 let mut directories = HashSet::new();
1363 for entry in index
1364 .entries
1365 .iter()
1366 .filter(|entry| entry.stage() == Stage::Normal)
1367 {
1368 let path = entry.path.as_bytes();
1369 for (idx, byte) in path.iter().enumerate() {
1370 if *byte == b'/' && idx > 0 {
1371 directories.insert(&path[..idx]);
1372 }
1373 }
1374 }
1375 directories
1376}
1377
1378pub(crate) fn status_untracked_directory_has_file<T: StatusTrackedLookup + ?Sized>(
1379 context: &mut StatusUntrackedWalk<'_, T>,
1380 dir: &Path,
1381 dir_git_path: &[u8],
1382) -> Result<bool> {
1383 if is_same_path(dir, context.git_dir) {
1384 return Ok(false);
1385 }
1386 let ignore_len = context.ignores.patterns.len();
1387 let mut entries = read_dir_entries_with_ignore_patterns(
1388 dir,
1389 dir_git_path,
1390 context.ignores,
1391 context.profile.as_deref_mut(),
1392 )?;
1393 entries.sort_by_key(|entry| entry.file_name());
1394 let result = (|| -> Result<bool> {
1395 let mut git_path = dir_git_path.to_vec();
1396 for entry in entries {
1397 let file_name = entry.file_name();
1398 if file_name == std::ffi::OsStr::new(".git") {
1399 continue;
1400 }
1401 let path_len = git_path_push_component(&mut git_path, &file_name);
1402 let entry_result = (|| -> Result<Option<bool>> {
1403 if let Some(profile) = context.profile.as_deref_mut() {
1404 profile.file_type_calls += 1;
1405 }
1406 let file_type = entry.file_type()?;
1407 let is_dir = file_type.is_dir();
1408 if context.ignores.is_ignored_profiled(
1409 &git_path,
1410 is_dir,
1411 context.profile.as_deref_mut(),
1412 ) {
1413 return Ok(None);
1414 }
1415 if file_type.is_file() || file_type.is_symlink() {
1416 return Ok(Some(true));
1417 }
1418 if is_dir {
1419 let path = entry.path();
1420 if is_same_path(&path, context.git_dir) {
1421 return Ok(None);
1422 }
1423 if is_nested_repository_boundary(&path, context.git_dir) {
1424 return Ok(Some(true));
1425 }
1426 if status_untracked_directory_has_file(context, &path, &git_path)? {
1427 return Ok(Some(true));
1428 }
1429 }
1430 Ok(None)
1431 })();
1432 git_path.truncate(path_len);
1433 if let Some(has_file) = entry_result? {
1434 return Ok(has_file);
1435 }
1436 }
1437 Ok(false)
1438 })();
1439 context.ignores.truncate(ignore_len);
1440 result
1441}
1442
1443pub(crate) fn read_dir_entries_with_ignore_patterns(
1444 dir: &Path,
1445 base: &[u8],
1446 matcher: &mut IgnoreMatcher,
1447 mut profile: Option<&mut StatusProfileCounters>,
1448) -> Result<Vec<fs::DirEntry>> {
1449 let mut entries = Vec::new();
1450 let mut ignore_path = None;
1451 if let Some(profile) = profile.as_deref_mut() {
1452 profile.read_dir_calls += 1;
1453 }
1454 for entry in fs::read_dir(dir)? {
1455 let entry = entry?;
1456 if let Some(profile) = profile.as_deref_mut() {
1457 profile.dir_entries_seen += 1;
1458 }
1459 if entry.file_name() == std::ffi::OsStr::new(".gitignore") {
1460 ignore_path = Some(entry.path());
1461 }
1462 entries.push(entry);
1463 }
1464 if let Some(profile) = profile {
1465 profile.read_dir_entry_vec_cap_bytes +=
1466 (entries.capacity() * std::mem::size_of::<fs::DirEntry>()) as u64;
1467 profile.read_dir_entry_vec_max_len =
1468 profile.read_dir_entry_vec_max_len.max(entries.len() as u64);
1469 profile.read_dir_entry_vec_max_cap = profile
1470 .read_dir_entry_vec_max_cap
1471 .max(entries.capacity() as u64);
1472 }
1473 if let Some(path) = ignore_path {
1474 let mut source = base.to_vec();
1475 if !source.is_empty() {
1476 source.push(b'/');
1477 }
1478 source.extend_from_slice(b".gitignore");
1479 read_per_directory_ignore_patterns_into_matcher(path, matcher, base, &source)?;
1480 }
1481 Ok(entries)
1482}
1483
1484pub(crate) fn build_untracked_cache(
1485 worktree_root: &Path,
1486 git_dir: &Path,
1487 format: ObjectFormat,
1488 index: &Index,
1489 untracked_mode: StatusUntrackedMode,
1490) -> Result<UntrackedCache> {
1491 let stat_cache = IndexStatCache::from_index(index, &repository_index_path(git_dir));
1492 let tracked_dirs = stage0_tracked_directories(index);
1493 let tracked = IndexStatusLookup {
1494 stat_cache: &stat_cache,
1495 tracked_dirs: &tracked_dirs,
1496 };
1497 let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
1498 let mut cache = UntrackedCache::new(
1499 format,
1500 untracked_cache_ident(worktree_root),
1501 untracked_cache_dir_flags(untracked_mode),
1502 );
1503 cache.info_exclude = untracked_cache_oid_stat(&git_dir.join("info").join("exclude"), format)?;
1504 cache.excludes_file = UntrackedCacheOidStat::new(format);
1505 cache.root = Some(build_untracked_cache_dir(
1506 worktree_root,
1507 git_dir,
1508 worktree_root,
1509 &[],
1510 b"",
1511 &tracked,
1512 &mut ignores,
1513 untracked_mode,
1514 format,
1515 false,
1516 )?);
1517 Ok(cache)
1518}
1519
1520pub(crate) fn emit_untracked_cache_trace(old: Option<&UntrackedCache>, new: &UntrackedCache) {
1521 sley_core::trace2::perf_read_directory_data("path", "");
1522 let dir_count = new
1523 .root
1524 .as_ref()
1525 .map(count_untracked_cache_dirs)
1526 .unwrap_or(0);
1527 let Some(old) = old else {
1528 sley_core::trace2::perf_read_directory_data("node-creation", dir_count.saturating_sub(1));
1529 sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 1);
1530 sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
1531 sley_core::trace2::perf_read_directory_data("opendir", dir_count);
1532 return;
1533 };
1534 let Some(old_root) = old.root.as_ref() else {
1535 sley_core::trace2::perf_read_directory_data("node-creation", dir_count.saturating_sub(1));
1536 sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 1);
1537 sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
1538 sley_core::trace2::perf_read_directory_data("opendir", dir_count);
1539 return;
1540 };
1541 let Some(new_root) = new.root.as_ref() else {
1542 return;
1543 };
1544 if old.ident != new.ident || old.dir_flags != new.dir_flags {
1545 sley_core::trace2::perf_read_directory_data("node-creation", dir_count.saturating_sub(1));
1546 sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 1);
1547 sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
1548 sley_core::trace2::perf_read_directory_data("opendir", dir_count);
1549 return;
1550 }
1551 if old.info_exclude.oid != new.info_exclude.oid
1552 || old.excludes_file.oid != new.excludes_file.oid
1553 {
1554 sley_core::trace2::perf_read_directory_data("node-creation", 0);
1555 sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 1);
1556 sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
1557 sley_core::trace2::perf_read_directory_data("opendir", dir_count);
1558 return;
1559 }
1560 if old_root.exclude_oid != new_root.exclude_oid {
1561 sley_core::trace2::perf_read_directory_data("node-creation", 0);
1562 sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 1);
1563 sley_core::trace2::perf_read_directory_data("directory-invalidation", 1);
1564 sley_core::trace2::perf_read_directory_data("opendir", dir_count);
1565 return;
1566 }
1567 let invalid_dir_count = count_invalid_untracked_cache_dirs(old_root);
1568 if invalid_dir_count > 0 {
1569 sley_core::trace2::perf_read_directory_data("node-creation", 0);
1570 sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 0);
1571 sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
1572 sley_core::trace2::perf_read_directory_data("opendir", invalid_dir_count);
1573 return;
1574 }
1575 if old_root.stat != new_root.stat {
1576 sley_core::trace2::perf_read_directory_data("node-creation", 0);
1577 sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 0);
1578 sley_core::trace2::perf_read_directory_data("directory-invalidation", 1);
1579 sley_core::trace2::perf_read_directory_data("opendir", 1);
1580 return;
1581 }
1582 if old.root == new.root {
1583 sley_core::trace2::perf_read_directory_data("node-creation", 0);
1584 sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 0);
1585 sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
1586 sley_core::trace2::perf_read_directory_data("opendir", 0);
1587 return;
1588 }
1589 sley_core::trace2::perf_read_directory_data("node-creation", 0);
1590 sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 0);
1591 sley_core::trace2::perf_read_directory_data("directory-invalidation", 1);
1592 sley_core::trace2::perf_read_directory_data("opendir", dir_count);
1593}
1594
1595pub(crate) fn count_untracked_cache_dirs(dir: &UntrackedCacheDir) -> usize {
1596 1 + dir
1597 .dirs
1598 .iter()
1599 .map(count_untracked_cache_dirs)
1600 .sum::<usize>()
1601}
1602
1603pub(crate) fn count_invalid_untracked_cache_dirs(dir: &UntrackedCacheDir) -> usize {
1604 usize::from(!dir.valid)
1605 + dir
1606 .dirs
1607 .iter()
1608 .map(count_invalid_untracked_cache_dirs)
1609 .sum::<usize>()
1610}
1611
1612#[allow(clippy::too_many_arguments)]
1613pub(crate) fn build_untracked_cache_dir<T: StatusTrackedLookup + ?Sized>(
1614 worktree_root: &Path,
1615 git_dir: &Path,
1616 dir: &Path,
1617 dir_git_path: &[u8],
1618 name: &[u8],
1619 tracked: &T,
1620 ignores: &mut IgnoreMatcher,
1621 untracked_mode: StatusUntrackedMode,
1622 format: ObjectFormat,
1623 check_only: bool,
1624) -> Result<UntrackedCacheDir> {
1625 let ignore_len = ignores.patterns.len();
1626 let mut entries = read_dir_entries_with_ignore_patterns(dir, dir_git_path, ignores, None)?;
1627 entries.sort_by_key(|entry| entry.file_name());
1628 let exclude_path = if dir_git_path.is_empty() {
1629 b".gitignore".to_vec()
1630 } else {
1631 let mut path = dir_git_path.to_vec();
1632 path.push(b'/');
1633 path.extend_from_slice(b".gitignore");
1634 path
1635 };
1636 let exclude_oid = if tracked.tracked_kind(&exclude_path).is_some() {
1637 None
1638 } else {
1639 per_directory_ignore_oid(dir, format)?
1640 };
1641 let mut node = UntrackedCacheDir {
1642 name: name.to_vec(),
1643 stat: fs::symlink_metadata(dir)
1644 .map(|metadata| untracked_cache_stat_data(&metadata))
1645 .unwrap_or_default(),
1646 exclude_oid,
1647 valid: true,
1648 check_only,
1649 recurse: true,
1650 ..UntrackedCacheDir::default()
1651 };
1652 let result = (|| -> Result<()> {
1653 let mut git_path = dir_git_path.to_vec();
1654 for entry in entries {
1655 let file_name = entry.file_name();
1656 if file_name == std::ffi::OsStr::new(".git") {
1657 continue;
1658 }
1659 let path_len = git_path_push_component(&mut git_path, &file_name);
1660 let entry_result = (|| -> Result<()> {
1661 if tracked.tracked_kind(&git_path).is_some() {
1662 return Ok(());
1663 }
1664 let file_type = entry.file_type()?;
1665 let is_dir = file_type.is_dir();
1666 if ignores.is_ignored(&git_path, is_dir) {
1667 return Ok(());
1668 }
1669 if file_type.is_file() || file_type.is_symlink() {
1670 node.untracked.push(component_name_bytes(&file_name));
1671 return Ok(());
1672 }
1673 if !is_dir {
1674 return Ok(());
1675 }
1676 let path = entry.path();
1677 if is_same_path(&path, git_dir) {
1678 return Ok(());
1679 }
1680 let component = component_name_bytes(&file_name);
1681 let tracked_directory = tracked.tracked_directory_kind(&git_path);
1682 let child_check_only = matches!(untracked_mode, StatusUntrackedMode::Normal)
1683 && tracked_directory.is_none();
1684 let child = build_untracked_cache_dir(
1685 worktree_root,
1686 git_dir,
1687 &path,
1688 &git_path,
1689 &component,
1690 tracked,
1691 ignores,
1692 untracked_mode,
1693 format,
1694 child_check_only,
1695 )?;
1696 let child_has_untracked = !child.untracked.is_empty()
1697 || child
1698 .dirs
1699 .iter()
1700 .any(|dir| !dir.untracked.is_empty() || !dir.dirs.is_empty());
1701 match untracked_mode {
1702 StatusUntrackedMode::All => {
1703 node.dirs.push(child);
1704 }
1705 StatusUntrackedMode::Normal => {
1706 if tracked_directory.is_some() {
1707 node.dirs.push(child);
1708 } else {
1709 if child_has_untracked {
1710 let mut directory = component.clone();
1711 directory.push(b'/');
1712 node.untracked.push(directory);
1713 }
1714 node.dirs.push(child);
1715 }
1716 }
1717 StatusUntrackedMode::None => {}
1718 }
1719 Ok(())
1720 })();
1721 git_path.truncate(path_len);
1722 entry_result?;
1723 }
1724 Ok(())
1725 })();
1726 ignores.truncate(ignore_len);
1727 result?;
1728 if worktree_root == dir {
1729 node.name.clear();
1730 }
1731 Ok(node)
1732}
1733
1734pub(crate) fn component_name_bytes(name: &std::ffi::OsStr) -> Vec<u8> {
1735 #[cfg(unix)]
1736 {
1737 use std::os::unix::ffi::OsStrExt;
1738 name.as_bytes().to_vec()
1739 }
1740 #[cfg(not(unix))]
1741 {
1742 name.to_string_lossy().as_bytes().to_vec()
1743 }
1744}
1745
1746pub(crate) fn per_directory_ignore_oid(
1747 dir: &Path,
1748 format: ObjectFormat,
1749) -> Result<Option<ObjectId>> {
1750 let path = dir.join(".gitignore");
1751 match fs::read(&path) {
1752 Ok(bytes) => Ok(Some(untracked_cache_exclude_oid(bytes, format)?)),
1753 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1754 Err(err) => Err(err.into()),
1755 }
1756}
1757
1758pub(crate) fn untracked_cache_oid_stat(
1759 path: &Path,
1760 format: ObjectFormat,
1761) -> Result<UntrackedCacheOidStat> {
1762 let stat = fs::symlink_metadata(path)
1763 .map(|metadata| untracked_cache_stat_data(&metadata))
1764 .unwrap_or_default();
1765 let oid = match fs::read(path) {
1766 Ok(bytes) => untracked_cache_exclude_oid(bytes, format)?,
1767 Err(err) if err.kind() == std::io::ErrorKind::NotFound => ObjectId::null(format),
1768 Err(err) => return Err(err.into()),
1769 };
1770 Ok(UntrackedCacheOidStat { stat, oid })
1771}
1772
1773pub(crate) fn untracked_cache_exclude_oid(
1774 mut bytes: Vec<u8>,
1775 format: ObjectFormat,
1776) -> Result<ObjectId> {
1777 if !bytes.is_empty() {
1778 bytes.push(b'\n');
1779 }
1780 EncodedObject::new(ObjectType::Blob, bytes).object_id(format)
1781}
1782
1783#[cfg(unix)]
1784pub(crate) fn untracked_cache_stat_data(metadata: &fs::Metadata) -> UntrackedCacheStatData {
1785 use std::os::unix::fs::MetadataExt;
1786 UntrackedCacheStatData {
1787 ctime_seconds: metadata.ctime().min(u32::MAX as i64).max(0) as u32,
1788 ctime_nanoseconds: metadata.ctime_nsec().min(u32::MAX as i64).max(0) as u32,
1789 mtime_seconds: metadata.mtime().min(u32::MAX as i64).max(0) as u32,
1790 mtime_nanoseconds: metadata.mtime_nsec().min(u32::MAX as i64).max(0) as u32,
1791 dev: metadata.dev() as u32,
1792 ino: metadata.ino() as u32,
1793 uid: metadata.uid(),
1794 gid: metadata.gid(),
1795 size: metadata.size().min(u32::MAX as u64) as u32,
1796 }
1797}
1798
1799#[cfg(not(unix))]
1800pub(crate) fn untracked_cache_stat_data(metadata: &fs::Metadata) -> UntrackedCacheStatData {
1801 let (mtime_seconds, mtime_nanoseconds) = file_mtime_parts(metadata).unwrap_or((0, 0));
1802 UntrackedCacheStatData {
1803 mtime_seconds: mtime_seconds.min(u64::from(u32::MAX)) as u32,
1804 mtime_nanoseconds: mtime_nanoseconds.min(u64::from(u32::MAX)) as u32,
1805 size: metadata.len().min(u64::from(u32::MAX)) as u32,
1806 ..UntrackedCacheStatData::default()
1807 }
1808}
1809
1810pub(crate) fn untracked_cache_dir_flags(untracked_mode: StatusUntrackedMode) -> u32 {
1811 match untracked_mode {
1812 StatusUntrackedMode::All => 0,
1813 StatusUntrackedMode::Normal | StatusUntrackedMode::None => {
1814 sley_index::untracked_cache_normal_flags()
1815 }
1816 }
1817}
1818
1819pub(crate) fn untracked_cache_ident(worktree_root: &Path) -> Vec<u8> {
1820 let mut ident = format!(
1821 "Location {}, system {}",
1822 worktree_root.display(),
1823 untracked_cache_system_name()
1824 )
1825 .into_bytes();
1826 ident.push(0);
1827 ident
1828}
1829
1830pub(crate) fn untracked_cache_system_name() -> String {
1831 fs::read_to_string("/proc/sys/kernel/ostype")
1832 .ok()
1833 .map(|name| name.trim().to_string())
1834 .filter(|name| !name.is_empty())
1835 .unwrap_or_else(|| {
1836 let os = std::env::consts::OS;
1837 let mut chars = os.chars();
1838 match chars.next() {
1839 Some(first) => first.to_uppercase().chain(chars).collect(),
1840 None => "Unknown".to_string(),
1841 }
1842 })
1843}
1844
1845pub(crate) fn push_untracked_directory(paths: &mut Vec<Vec<u8>>, git_path: &[u8]) {
1846 paths.push(untracked_directory_path(git_path));
1847}
1848
1849pub(crate) fn untracked_directory_path(git_path: &[u8]) -> Vec<u8> {
1850 let mut directory = git_path.to_vec();
1851 if directory.last() != Some(&b'/') {
1852 directory.push(b'/');
1853 }
1854 directory
1855}
1856
1857pub(crate) fn untracked_normal_rollup_path(
1858 file_path: &[u8],
1859 index: &BTreeMap<Vec<u8>, TrackedEntry>,
1860 ignores: &IgnoreMatcher,
1861) -> Vec<u8> {
1862 let segments = file_path
1863 .split(|byte| *byte == b'/')
1864 .filter(|segment| !segment.is_empty())
1865 .collect::<Vec<_>>();
1866 if segments.len() <= 1 {
1867 return file_path.to_vec();
1868 }
1869 let mut prefix = Vec::new();
1870 for segment in &segments[..segments.len() - 1] {
1871 if !prefix.is_empty() {
1872 prefix.push(b'/');
1873 }
1874 prefix.extend_from_slice(segment);
1875 if index_has_path_under(index, &prefix) {
1876 break;
1877 }
1878 if !ignores.is_ignored(&prefix, true) {
1879 let mut directory = prefix;
1880 directory.push(b'/');
1881 return directory;
1882 }
1883 }
1884 file_path.to_vec()
1885}
1886
1887pub(crate) fn ignored_traditional_rollup_path(
1888 root: &Path,
1889 git_dir: &Path,
1890 path: &[u8],
1891 index: &BTreeMap<Vec<u8>, TrackedEntry>,
1892 ignores: &IgnoreMatcher,
1893) -> Result<Vec<u8>> {
1894 let rolled = untracked_normal_rollup_path(path, index, ignores);
1895 if rolled == path {
1896 return Ok(rolled);
1897 }
1898 let Some(directory_path) = rolled.strip_suffix(b"/") else {
1899 return Ok(rolled);
1900 };
1901 if ignores.is_ignored(directory_path, true) {
1902 return Ok(rolled);
1903 }
1904 let mut absolute = PathBuf::new();
1905 set_worktree_path_from_repo_path(root, directory_path, &mut absolute)?;
1906 if directory_has_file(&absolute, root, git_dir, ignores)? {
1907 return Ok(path.to_vec());
1908 }
1909 Ok(rolled)
1910}
1911
1912pub(crate) fn directory_has_file(
1913 dir: &Path,
1914 root: &Path,
1915 git_dir: &Path,
1916 ignores: &IgnoreMatcher,
1917) -> Result<bool> {
1918 if is_same_path(dir, git_dir) {
1919 return Ok(false);
1920 }
1921 for entry in fs::read_dir(dir)? {
1922 let entry = entry?;
1923 let path = entry.path();
1924 if is_dot_git_entry(&path) {
1925 continue;
1926 }
1927 if is_embedded_git_internals(root, &path) {
1928 continue;
1929 }
1930 if is_same_path(&path, git_dir) {
1931 continue;
1932 }
1933 let metadata = entry.metadata()?;
1934 let relative = path.strip_prefix(root).map_err(|_| {
1935 GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1936 })?;
1937 let git_path = git_path_bytes(relative)?;
1938 if ignores.is_ignored(&git_path, metadata.is_dir()) {
1939 continue;
1940 }
1941 if metadata.is_file() || metadata.file_type().is_symlink() {
1942 return Ok(true);
1943 }
1944 if metadata.is_dir() {
1945 if is_nested_repository_boundary(&path, git_dir) {
1946 continue;
1947 }
1948 if directory_has_file(&path, root, git_dir, ignores)? {
1949 return Ok(true);
1950 }
1951 }
1952 }
1953 Ok(false)
1954}
1955
1956pub(crate) fn directory_has_ignored(
1957 dir: &Path,
1958 root: &Path,
1959 git_dir: &Path,
1960 ignores: &IgnoreMatcher,
1961) -> Result<bool> {
1962 if is_same_path(dir, git_dir) {
1963 return Ok(false);
1964 }
1965 for entry in fs::read_dir(dir)? {
1966 let entry = entry?;
1967 let path = entry.path();
1968 if is_dot_git_entry(&path) {
1969 continue;
1970 }
1971 if is_same_path(&path, git_dir) {
1972 continue;
1973 }
1974 let metadata = entry.metadata()?;
1975 let relative = path.strip_prefix(root).map_err(|_| {
1976 GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1977 })?;
1978 let git_path = git_path_bytes(relative)?;
1979 if ignores.is_ignored(&git_path, metadata.is_dir()) {
1980 return Ok(true);
1981 }
1982 if metadata.is_dir() && directory_has_ignored(&path, root, git_dir, ignores)? {
1983 return Ok(true);
1984 }
1985 }
1986 Ok(false)
1987}
1988
1989pub(crate) fn ignored_untracked_paths(
1990 root: &Path,
1991 git_dir: &Path,
1992 index: &BTreeMap<Vec<u8>, TrackedEntry>,
1993 ignores: &IgnoreMatcher,
1994 directory: bool,
1995) -> Result<Vec<Vec<u8>>> {
1996 let mut paths = BTreeSet::new();
1997 let context = IgnoredUntrackedContext {
1998 root,
1999 git_dir,
2000 index,
2001 ignores,
2002 directory,
2003 };
2004 collect_ignored_untracked_paths(&context, root, false, &mut paths)?;
2005 Ok(paths.into_iter().collect())
2006}
2007
2008pub(crate) fn ignored_traditional_path_is_empty_directory(
2009 root: &Path,
2010 path: &[u8],
2011) -> Result<bool> {
2012 let Some(path) = path.strip_suffix(b"/") else {
2013 return Ok(false);
2014 };
2015 let mut absolute = PathBuf::new();
2016 set_worktree_path_from_repo_path(root, path, &mut absolute)?;
2017 match fs::read_dir(&absolute) {
2018 Ok(mut entries) => Ok(entries.next().is_none()),
2019 Err(err) if err.kind() == std::io::ErrorKind::NotADirectory => Ok(false),
2020 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
2021 Err(err) => Err(err.into()),
2022 }
2023}
2024
2025pub(crate) struct IgnoredUntrackedContext<'a> {
2026 root: &'a Path,
2027 git_dir: &'a Path,
2028 index: &'a BTreeMap<Vec<u8>, TrackedEntry>,
2029 ignores: &'a IgnoreMatcher,
2030 directory: bool,
2031}
2032
2033pub(crate) fn collect_ignored_untracked_paths(
2034 context: &IgnoredUntrackedContext<'_>,
2035 dir: &Path,
2036 parent_ignored: bool,
2037 paths: &mut BTreeSet<Vec<u8>>,
2038) -> Result<()> {
2039 if is_same_path(dir, context.git_dir) {
2040 return Ok(());
2041 }
2042 let mut entries = fs::read_dir(dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
2043 entries.sort_by_key(|entry| entry.file_name());
2044 for entry in entries {
2045 let path = entry.path();
2046 if is_dot_git_entry(&path) {
2047 continue;
2048 }
2049 if is_same_path(&path, context.git_dir) {
2050 continue;
2051 }
2052 let metadata = entry.metadata()?;
2053 let relative = path.strip_prefix(context.root).map_err(|_| {
2054 GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
2055 })?;
2056 let git_path = git_path_bytes(relative)?;
2057 if metadata.is_dir() {
2058 let ignored = parent_ignored || context.ignores.is_ignored(&git_path, true);
2059 if ignored && !index_has_path_under(context.index, &git_path) {
2060 if context.directory || is_nested_repository_boundary(&path, context.git_dir) {
2061 let mut directory_path = git_path;
2062 directory_path.push(b'/');
2063 paths.insert(directory_path);
2064 } else {
2065 collect_ignored_untracked_paths(context, &path, true, paths)?;
2066 }
2067 } else {
2068 if is_nested_repository_boundary(&path, context.git_dir) {
2069 continue;
2070 }
2071 collect_ignored_untracked_paths(context, &path, ignored, paths)?;
2072 }
2073 } else if !context.index.contains_key(&git_path)
2074 && (metadata.is_file() || metadata.file_type().is_symlink())
2075 && (parent_ignored || context.ignores.is_ignored(&git_path, false))
2076 {
2077 paths.insert(git_path);
2078 }
2079 }
2080 Ok(())
2081}
2082
2083#[derive(Debug, Clone, Default)]
2084pub(crate) struct IgnoreMatcher {
2085 pub(crate) patterns: Vec<IgnorePattern>,
2086 pub(crate) buckets: IgnorePatternBuckets,
2087}
2088
2089#[derive(Debug, Clone, Default)]
2090pub(crate) struct IgnorePatternBuckets {
2091 pub(crate) literal_basename: HashMap<Vec<u8>, Vec<usize>>,
2092 pub(crate) directory_literal_basename: HashMap<Vec<u8>, Vec<usize>>,
2093 pub(crate) literal_path_basename: HashMap<Vec<u8>, Vec<usize>>,
2094 pub(crate) directory_literal_path_basename: HashMap<Vec<u8>, Vec<usize>>,
2095 pub(crate) path_suffix_basename: HashMap<Vec<u8>, Vec<usize>>,
2096 pub(crate) directory_path_suffix_basename: HashMap<Vec<u8>, Vec<usize>>,
2097 pub(crate) glob_path_literal_basename: HashMap<Vec<u8>, Vec<usize>>,
2098 pub(crate) glob_directory_literal_basename: HashMap<Vec<u8>, Vec<usize>>,
2099 pub(crate) glob_path_suffix_basename: Vec<usize>,
2100 pub(crate) glob_path_prefix_basename: Vec<usize>,
2101 pub(crate) glob_directory_suffix_basename: Vec<usize>,
2102 pub(crate) glob_directory_prefix_basename: Vec<usize>,
2103 pub(crate) suffix_basename: HashMap<u8, Vec<usize>>,
2104 pub(crate) prefix_basename: HashMap<u8, Vec<usize>>,
2105 pub(crate) other: Vec<usize>,
2106}
2107
2108impl IgnorePatternBuckets {
2109 fn push(&mut self, index: usize, pattern: &IgnorePattern) {
2110 match pattern.bucket_kind() {
2111 IgnoreBucketKind::LiteralBasename => self
2112 .literal_basename
2113 .entry(pattern.pattern.clone())
2114 .or_default()
2115 .push(index),
2116 IgnoreBucketKind::DirectoryLiteralBasename => self
2117 .directory_literal_basename
2118 .entry(pattern.pattern.clone())
2119 .or_default()
2120 .push(index),
2121 IgnoreBucketKind::LiteralPathBasename => self
2122 .literal_path_basename
2123 .entry(path_basename(&pattern.pattern).to_vec())
2124 .or_default()
2125 .push(index),
2126 IgnoreBucketKind::DirectoryLiteralPathBasename => self
2127 .directory_literal_path_basename
2128 .entry(path_basename(&pattern.pattern).to_vec())
2129 .or_default()
2130 .push(index),
2131 IgnoreBucketKind::PathSuffixBasename => {
2132 let suffix = pattern
2133 .pattern
2134 .strip_prefix(b"**/")
2135 .unwrap_or(&pattern.pattern);
2136 self.path_suffix_basename
2137 .entry(path_basename(suffix).to_vec())
2138 .or_default()
2139 .push(index);
2140 }
2141 IgnoreBucketKind::DirectoryPathSuffixBasename => {
2142 let suffix = pattern
2143 .pattern
2144 .strip_prefix(b"**/")
2145 .unwrap_or(&pattern.pattern);
2146 self.directory_path_suffix_basename
2147 .entry(path_basename(suffix).to_vec())
2148 .or_default()
2149 .push(index);
2150 }
2151 IgnoreBucketKind::GlobPathLiteralBasename => self
2152 .glob_path_literal_basename
2153 .entry(path_basename(&pattern.pattern).to_vec())
2154 .or_default()
2155 .push(index),
2156 IgnoreBucketKind::GlobDirectoryLiteralBasename => self
2157 .glob_directory_literal_basename
2158 .entry(path_basename(&pattern.pattern).to_vec())
2159 .or_default()
2160 .push(index),
2161 IgnoreBucketKind::GlobPathSuffixBasename => self.glob_path_suffix_basename.push(index),
2162 IgnoreBucketKind::GlobPathPrefixBasename => self.glob_path_prefix_basename.push(index),
2163 IgnoreBucketKind::GlobDirectorySuffixBasename => {
2164 self.glob_directory_suffix_basename.push(index)
2165 }
2166 IgnoreBucketKind::GlobDirectoryPrefixBasename => {
2167 self.glob_directory_prefix_basename.push(index)
2168 }
2169 IgnoreBucketKind::SuffixBasename => self
2170 .suffix_basename
2171 .entry(*pattern.pattern.last().expect("suffix literal is non-empty"))
2172 .or_default()
2173 .push(index),
2174 IgnoreBucketKind::PrefixBasename => self
2175 .prefix_basename
2176 .entry(pattern.pattern[0])
2177 .or_default()
2178 .push(index),
2179 IgnoreBucketKind::Other => self.other.push(index),
2180 }
2181 }
2182
2183 fn truncate(&mut self, len: usize) {
2184 fn truncate_indices(indices: &mut Vec<usize>, len: usize) {
2185 let keep = indices.partition_point(|index| *index < len);
2186 indices.truncate(keep);
2187 }
2188 for indices in self.literal_basename.values_mut() {
2189 truncate_indices(indices, len);
2190 }
2191 for indices in self.directory_literal_basename.values_mut() {
2192 truncate_indices(indices, len);
2193 }
2194 for indices in self.literal_path_basename.values_mut() {
2195 truncate_indices(indices, len);
2196 }
2197 for indices in self.directory_literal_path_basename.values_mut() {
2198 truncate_indices(indices, len);
2199 }
2200 for indices in self.path_suffix_basename.values_mut() {
2201 truncate_indices(indices, len);
2202 }
2203 for indices in self.directory_path_suffix_basename.values_mut() {
2204 truncate_indices(indices, len);
2205 }
2206 for indices in self.glob_path_literal_basename.values_mut() {
2207 truncate_indices(indices, len);
2208 }
2209 for indices in self.glob_directory_literal_basename.values_mut() {
2210 truncate_indices(indices, len);
2211 }
2212 truncate_indices(&mut self.glob_path_suffix_basename, len);
2213 truncate_indices(&mut self.glob_path_prefix_basename, len);
2214 truncate_indices(&mut self.glob_directory_suffix_basename, len);
2215 truncate_indices(&mut self.glob_directory_prefix_basename, len);
2216 for indices in self.suffix_basename.values_mut() {
2217 truncate_indices(indices, len);
2218 }
2219 for indices in self.prefix_basename.values_mut() {
2220 truncate_indices(indices, len);
2221 }
2222 truncate_indices(&mut self.other, len);
2223 }
2224
2225 fn profile_map_count(&self) -> usize {
2226 self.literal_basename.len()
2227 + self.directory_literal_basename.len()
2228 + self.literal_path_basename.len()
2229 + self.directory_literal_path_basename.len()
2230 + self.path_suffix_basename.len()
2231 + self.directory_path_suffix_basename.len()
2232 + self.glob_path_literal_basename.len()
2233 + self.glob_directory_literal_basename.len()
2234 + self.suffix_basename.len()
2235 + self.prefix_basename.len()
2236 }
2237
2238 fn profile_index_count(&self) -> usize {
2239 fn map_indices<K>(map: &HashMap<K, Vec<usize>>) -> usize {
2240 map.values().map(Vec::len).sum()
2241 }
2242 map_indices(&self.literal_basename)
2243 + map_indices(&self.directory_literal_basename)
2244 + map_indices(&self.literal_path_basename)
2245 + map_indices(&self.directory_literal_path_basename)
2246 + map_indices(&self.path_suffix_basename)
2247 + map_indices(&self.directory_path_suffix_basename)
2248 + map_indices(&self.glob_path_literal_basename)
2249 + map_indices(&self.glob_directory_literal_basename)
2250 + self.glob_path_suffix_basename.len()
2251 + self.glob_path_prefix_basename.len()
2252 + self.glob_directory_suffix_basename.len()
2253 + self.glob_directory_prefix_basename.len()
2254 + map_indices(&self.suffix_basename)
2255 + map_indices(&self.prefix_basename)
2256 + self.other.len()
2257 }
2258
2259 fn profile_index_vec_bytes(&self) -> usize {
2260 fn map_bytes<K>(map: &HashMap<K, Vec<usize>>) -> usize {
2261 map.values()
2262 .map(|indices| indices.capacity() * std::mem::size_of::<usize>())
2263 .sum()
2264 }
2265 map_bytes(&self.literal_basename)
2266 + map_bytes(&self.directory_literal_basename)
2267 + map_bytes(&self.literal_path_basename)
2268 + map_bytes(&self.directory_literal_path_basename)
2269 + map_bytes(&self.path_suffix_basename)
2270 + map_bytes(&self.directory_path_suffix_basename)
2271 + map_bytes(&self.glob_path_literal_basename)
2272 + map_bytes(&self.glob_directory_literal_basename)
2273 + self.glob_path_suffix_basename.capacity() * std::mem::size_of::<usize>()
2274 + self.glob_path_prefix_basename.capacity() * std::mem::size_of::<usize>()
2275 + self.glob_directory_suffix_basename.capacity() * std::mem::size_of::<usize>()
2276 + self.glob_directory_prefix_basename.capacity() * std::mem::size_of::<usize>()
2277 + map_bytes(&self.suffix_basename)
2278 + map_bytes(&self.prefix_basename)
2279 + self.other.capacity() * std::mem::size_of::<usize>()
2280 }
2281}
2282
2283#[derive(Debug, Clone)]
2284pub(crate) struct IgnorePattern {
2285 pub(crate) base: Vec<u8>,
2286 pub(crate) pattern: Vec<u8>,
2287 pub(crate) original: Vec<u8>,
2288 pub(crate) source: Vec<u8>,
2289 pub(crate) line_number: usize,
2290 pub(crate) negated: bool,
2291 pub(crate) directory_only: bool,
2292 pub(crate) anchored: bool,
2293 pub(crate) has_slash: bool,
2294 pub(crate) match_kind: MatchKind,
2299 pub(crate) glob_literal_prefix_len: usize,
2300}
2301
2302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2307pub(crate) enum MatchKind {
2308 Literal,
2310 Suffix,
2312 Prefix,
2314 PathSuffix,
2316 Glob,
2318}
2319
2320pub(crate) fn path_basename(path: &[u8]) -> &[u8] {
2321 path.rsplit(|byte| *byte == b'/').next().unwrap_or(path)
2322}
2323
2324pub(crate) fn path_component_has_glob_meta(component: &[u8]) -> bool {
2325 component
2326 .iter()
2327 .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b'\\'))
2328}
2329
2330pub(crate) fn final_component_match_kind(pattern: &[u8]) -> MatchKind {
2331 classify_ignore_pattern(path_basename(pattern))
2332}
2333
2334pub(crate) fn visit_directory_match_components(
2335 path: &[u8],
2336 is_dir: bool,
2337 mut visit: impl FnMut(&[u8]),
2338) {
2339 let mut start = 0usize;
2340 for (index, byte) in path.iter().enumerate() {
2341 if *byte == b'/' {
2342 if index > start {
2343 visit(&path[start..index]);
2344 }
2345 start = index + 1;
2346 }
2347 }
2348 if is_dir && start < path.len() {
2349 visit(&path[start..]);
2350 }
2351}
2352
2353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2354pub(crate) enum IgnoreBucketKind {
2355 LiteralBasename,
2356 DirectoryLiteralBasename,
2357 LiteralPathBasename,
2358 DirectoryLiteralPathBasename,
2359 PathSuffixBasename,
2360 DirectoryPathSuffixBasename,
2361 GlobPathLiteralBasename,
2362 GlobDirectoryLiteralBasename,
2363 GlobPathSuffixBasename,
2364 GlobPathPrefixBasename,
2365 GlobDirectorySuffixBasename,
2366 GlobDirectoryPrefixBasename,
2367 SuffixBasename,
2368 PrefixBasename,
2369 Other,
2370}
2371
2372pub(crate) fn classify_ignore_pattern(pattern: &[u8]) -> MatchKind {
2376 if let Some(suffix) = pattern.strip_prefix(b"**/")
2377 && !suffix.is_empty()
2378 && !suffix
2379 .iter()
2380 .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b'\\'))
2381 {
2382 return MatchKind::PathSuffix;
2383 }
2384 let stars = pattern.iter().filter(|byte| **byte == b'*').count();
2385 let other_meta = pattern
2386 .iter()
2387 .any(|byte| matches!(byte, b'?' | b'[' | b'\\'));
2388 if stars == 0 && !other_meta {
2389 return MatchKind::Literal;
2390 }
2391 if stars == 1 && !other_meta {
2392 let literal = if pattern.first() == Some(&b'*') {
2393 Some((&pattern[1..], MatchKind::Suffix))
2394 } else if pattern.last() == Some(&b'*') {
2395 Some((&pattern[..pattern.len() - 1], MatchKind::Prefix))
2396 } else {
2397 None
2398 };
2399 if let Some((literal, kind)) = literal
2400 && !literal.is_empty()
2401 && !literal.contains(&b'/')
2402 {
2403 return kind;
2404 }
2405 }
2406 MatchKind::Glob
2407}
2408
2409impl IgnoreMatcher {
2410 pub(crate) fn emit_memory_profile(&self, label: &str) {
2411 let pattern_payload_bytes = self
2412 .patterns
2413 .iter()
2414 .map(|pattern| {
2415 pattern.base.capacity()
2416 + pattern.pattern.capacity()
2417 + pattern.original.capacity()
2418 + pattern.source.capacity()
2419 })
2420 .sum();
2421 status_profile_mem(
2422 label,
2423 &[
2424 ("ignore_patterns_len", self.patterns.len()),
2425 ("ignore_patterns_cap", self.patterns.capacity()),
2426 (
2427 "ignore_pattern_struct_bytes",
2428 self.patterns.capacity() * std::mem::size_of::<IgnorePattern>(),
2429 ),
2430 ("ignore_pattern_payload_bytes", pattern_payload_bytes),
2431 ("ignore_bucket_map_count", self.buckets.profile_map_count()),
2432 (
2433 "ignore_bucket_index_count",
2434 self.buckets.profile_index_count(),
2435 ),
2436 (
2437 "ignore_bucket_index_vec_bytes",
2438 self.buckets.profile_index_vec_bytes(),
2439 ),
2440 ],
2441 );
2442 }
2443
2444 fn from_sources(
2445 root: &Path,
2446 exclude_standard: bool,
2447 patterns: &[Vec<u8>],
2448 per_directory: &[String],
2449 ) -> Result<Self> {
2450 let mut matcher = if exclude_standard {
2451 Self::from_worktree_root(root)?
2452 } else {
2453 Self::default()
2454 };
2455 matcher.extend_patterns(patterns);
2456 matcher.extend_per_directory_patterns(root, per_directory)?;
2457 Ok(matcher)
2458 }
2459
2460 pub(crate) fn from_worktree_base(root: &Path) -> Result<Self> {
2466 let mut matcher = Self::default();
2467 if !read_core_excludes_file(root, &mut matcher.patterns) {
2468 read_default_global_excludes_file(&mut matcher.patterns);
2469 }
2470 read_ignore_patterns(
2471 root.join(".git").join("info").join("exclude"),
2472 &mut matcher.patterns,
2473 &[],
2474 b".git/info/exclude",
2475 );
2476 matcher.rebuild_buckets();
2477 Ok(matcher)
2478 }
2479
2480 pub(crate) fn from_worktree_root(root: &Path) -> Result<Self> {
2481 let mut matcher = Self::default();
2482 if !read_core_excludes_file(root, &mut matcher.patterns) {
2483 read_default_global_excludes_file(&mut matcher.patterns);
2484 }
2485 read_ignore_patterns(
2486 root.join(".git").join("info").join("exclude"),
2487 &mut matcher.patterns,
2488 &[],
2489 b".git/info/exclude",
2490 );
2491 matcher.rebuild_buckets();
2492 collect_per_directory_patterns_into_matcher(
2493 root,
2494 root,
2495 &[String::from(".gitignore")],
2496 &mut matcher,
2497 )?;
2498 Ok(matcher)
2499 }
2500
2501 pub(crate) fn extend_patterns(&mut self, patterns: &[Vec<u8>]) {
2502 for pattern in patterns {
2503 self.push_raw_pattern(pattern, &[], &[], 0);
2504 }
2505 }
2506
2507 fn extend_per_directory_patterns(&mut self, root: &Path, names: &[String]) -> Result<()> {
2508 if names.is_empty() {
2509 return Ok(());
2510 }
2511 collect_per_directory_patterns_into_matcher(root, root, names, self)?;
2512 Ok(())
2513 }
2514
2515 pub(crate) fn is_ignored(&self, path: &[u8], is_dir: bool) -> bool {
2516 self.is_ignored_profiled(path, is_dir, None)
2517 }
2518
2519 fn match_for(&self, path: &[u8], is_dir: bool) -> Option<&IgnorePattern> {
2520 self.match_index_for(path, is_dir, None)
2521 .and_then(|index| self.patterns.get(index))
2522 }
2523
2524 fn is_ignored_profiled(
2525 &self,
2526 path: &[u8],
2527 is_dir: bool,
2528 mut profile: Option<&mut StatusProfileCounters>,
2529 ) -> bool {
2530 if let Some(profile) = profile.as_deref_mut() {
2531 profile.ignore_checks += 1;
2532 }
2533 self.match_index_for(path, is_dir, profile)
2534 .is_some_and(|index| !self.patterns[index].negated)
2535 }
2536
2537 fn match_index_for(
2538 &self,
2539 path: &[u8],
2540 is_dir: bool,
2541 mut profile: Option<&mut StatusProfileCounters>,
2542 ) -> Option<usize> {
2543 let basename = path_basename(path);
2544 let mut best = None;
2545 if let Some(indices) = self.buckets.literal_basename.get(basename) {
2546 self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
2547 }
2548 if let Some(indices) = self.buckets.literal_path_basename.get(basename) {
2549 self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
2550 }
2551 if let Some(indices) = self.buckets.path_suffix_basename.get(basename) {
2552 self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
2553 }
2554 if let Some(indices) = self.buckets.glob_path_literal_basename.get(basename) {
2555 self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
2556 }
2557 self.match_final_component_candidates(
2558 &self.buckets.glob_path_suffix_basename,
2559 MatchKind::Suffix,
2560 basename,
2561 path,
2562 basename,
2563 is_dir,
2564 &mut best,
2565 &mut profile,
2566 );
2567 self.match_final_component_candidates(
2568 &self.buckets.glob_path_prefix_basename,
2569 MatchKind::Prefix,
2570 basename,
2571 path,
2572 basename,
2573 is_dir,
2574 &mut best,
2575 &mut profile,
2576 );
2577 visit_directory_match_components(path, is_dir, |component| {
2578 if let Some(indices) = self.buckets.directory_literal_basename.get(component) {
2579 self.match_bucket_candidates(
2580 indices,
2581 path,
2582 basename,
2583 is_dir,
2584 &mut best,
2585 &mut profile,
2586 );
2587 }
2588 if let Some(indices) = self.buckets.directory_literal_path_basename.get(component) {
2589 self.match_bucket_candidates(
2590 indices,
2591 path,
2592 basename,
2593 is_dir,
2594 &mut best,
2595 &mut profile,
2596 );
2597 }
2598 if let Some(indices) = self.buckets.directory_path_suffix_basename.get(component) {
2599 self.match_bucket_candidates(
2600 indices,
2601 path,
2602 basename,
2603 is_dir,
2604 &mut best,
2605 &mut profile,
2606 );
2607 }
2608 if let Some(indices) = self.buckets.glob_directory_literal_basename.get(component) {
2609 self.match_bucket_candidates(
2610 indices,
2611 path,
2612 basename,
2613 is_dir,
2614 &mut best,
2615 &mut profile,
2616 );
2617 }
2618 self.match_final_component_candidates(
2619 &self.buckets.glob_directory_suffix_basename,
2620 MatchKind::Suffix,
2621 component,
2622 path,
2623 basename,
2624 is_dir,
2625 &mut best,
2626 &mut profile,
2627 );
2628 self.match_final_component_candidates(
2629 &self.buckets.glob_directory_prefix_basename,
2630 MatchKind::Prefix,
2631 component,
2632 path,
2633 basename,
2634 is_dir,
2635 &mut best,
2636 &mut profile,
2637 );
2638 });
2639 if let Some(last) = basename.last()
2640 && let Some(indices) = self.buckets.suffix_basename.get(last)
2641 {
2642 self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
2643 }
2644 if let Some(first) = basename.first()
2645 && let Some(indices) = self.buckets.prefix_basename.get(first)
2646 {
2647 self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
2648 }
2649 self.match_bucket_candidates(
2650 &self.buckets.other,
2651 path,
2652 basename,
2653 is_dir,
2654 &mut best,
2655 &mut profile,
2656 );
2657 best
2658 }
2659
2660 fn match_bucket_candidates(
2661 &self,
2662 indices: &[usize],
2663 path: &[u8],
2664 basename: &[u8],
2665 is_dir: bool,
2666 best: &mut Option<usize>,
2667 profile: &mut Option<&mut StatusProfileCounters>,
2668 ) {
2669 for &index in indices.iter().rev() {
2670 if best.is_some_and(|best| index <= best) {
2671 break;
2672 }
2673 let pattern = &self.patterns[index];
2674 if !pattern.base_matches(path) {
2675 continue;
2676 }
2677 if !pattern.glob_literal_prefix_matches(path, basename, is_dir) {
2678 continue;
2679 }
2680 if let Some(profile) = profile.as_deref_mut() {
2681 profile.ignore_pattern_tests += 1;
2682 if pattern.match_kind == MatchKind::Glob {
2683 profile.ignore_glob_fallback_tests += 1;
2684 }
2685 }
2686 if pattern.matches_with_basename(path, basename, is_dir) {
2687 *best = Some(index);
2688 break;
2689 }
2690 }
2691 }
2692
2693 fn match_final_component_candidates(
2694 &self,
2695 indices: &[usize],
2696 kind: MatchKind,
2697 component: &[u8],
2698 path: &[u8],
2699 basename: &[u8],
2700 is_dir: bool,
2701 best: &mut Option<usize>,
2702 profile: &mut Option<&mut StatusProfileCounters>,
2703 ) {
2704 for &index in indices.iter().rev() {
2705 if best.is_some_and(|best| index <= best) {
2706 break;
2707 }
2708 let pattern = &self.patterns[index];
2709 if !pattern.base_matches(path) {
2710 continue;
2711 }
2712 let final_component = path_basename(&pattern.pattern);
2713 let candidate = match kind {
2714 MatchKind::Suffix => component.ends_with(&final_component[1..]),
2715 MatchKind::Prefix => {
2716 component.starts_with(&final_component[..final_component.len() - 1])
2717 }
2718 _ => false,
2719 };
2720 if !candidate {
2721 continue;
2722 }
2723 if !pattern.glob_literal_prefix_matches(path, basename, is_dir) {
2724 continue;
2725 }
2726 if let Some(profile) = profile.as_deref_mut() {
2727 profile.ignore_pattern_tests += 1;
2728 if pattern.match_kind == MatchKind::Glob {
2729 profile.ignore_glob_fallback_tests += 1;
2730 }
2731 }
2732 if pattern.matches_with_basename(path, basename, is_dir) {
2733 *best = Some(index);
2734 break;
2735 }
2736 }
2737 }
2738
2739 fn push_pattern(&mut self, pattern: IgnorePattern) {
2740 let index = self.patterns.len();
2741 self.buckets.push(index, &pattern);
2742 self.patterns.push(pattern);
2743 }
2744
2745 pub(crate) fn push_raw_pattern(
2746 &mut self,
2747 raw: &[u8],
2748 base: &[u8],
2749 source: &[u8],
2750 line_number: usize,
2751 ) {
2752 if let Some(pattern) = parse_ignore_pattern(raw, base, source, line_number) {
2753 self.push_pattern(pattern);
2754 }
2755 }
2756
2757 fn truncate(&mut self, len: usize) {
2758 if self.patterns.len() == len {
2759 return;
2760 }
2761 self.patterns.truncate(len);
2762 self.buckets.truncate(len);
2763 }
2764
2765 fn rebuild_buckets(&mut self) {
2766 let mut buckets = IgnorePatternBuckets::default();
2767 for (index, pattern) in self.patterns.iter().enumerate() {
2768 buckets.push(index, pattern);
2769 }
2770 self.buckets = buckets;
2771 }
2772}