Skip to main content

sley_worktree/
ignore.rs

1//! Untracked-path discovery, `.gitignore` matching, the untracked-cache builder, and the ignore matcher.
2//!
3//! Split out of `lib.rs` in the wave-47 mechanical refactor: a pure code move
4//! (no function body changed); all items are re-exported from `lib.rs`.
5use 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/// Pathspec filter for untracked collection. Mirrors git `ls-files` pathspec
26/// semantics: literal paths, recursive directory prefixes, and fnmatch globs.
27#[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
46// The wildmatch engine and the single-item pathspec matcher now live in the
47// shared `sley-pathspec` crate. Re-export them so existing `sley-worktree`
48// callers (and the t3070 `ls-files` path) keep their public surface unchanged.
49pub use sley_pathspec::{
50    PathspecMatchMagic, WM_CASEFOLD, WM_PATHNAME, pathspec_is_glob, pathspec_item_matches,
51    wildmatch,
52};
53
54/// Whether `path` matches an `ls-files` pathspec (literal, directory prefix, or glob).
55pub 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
78/// Whether a directory walk must descend into `parent` to satisfy active pathspecs.
79pub 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
111/// Whether some pathspec selects the directory `git_path` *as a whole* (so an
112/// untracked directory can roll up to `dir/` under `--directory`), as opposed to
113/// only matching something strictly below it (which forces descent). A
114/// directory-prefix pathspec covering the directory, an exact directory match, or
115/// a glob matching the directory's own name all count; a deeper glob such as
116/// `dir/*.c` or an exact file path inside the directory does not.
117pub(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
166/// fnmatch-style glob where `*` and `?` match any byte including `/`.
167pub(crate) fn untracked_wildmatch(pattern: &[u8], text: &[u8]) -> bool {
168    // Untracked-walk pathspec globs match with PATHMATCH semantics (`*` crosses
169    // `/`), matching git's default (non-GLOB-magic) pathspec behavior.
170    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
249/// Untracked paths for `ls-files --others` (without `--directory`): every
250/// untracked file is listed individually, except embedded-repository boundaries
251/// which are emitted as `dir/` to match git's non-submodule `.git` handling.
252pub(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
302/// A reusable matcher for standard worktree attributes (global or
303/// `core.attributesFile`, every in-tree `.gitattributes`, and
304/// `$GIT_DIR/info/attributes`).
305///
306/// This is behaviourally identical to [`standard_attributes_for_path`] except
307/// the attribute sources are read once and reused for each path.
308pub 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                // A pathspec reaches into this wholly-untracked directory. Git's
521                // `--directory` still rolls it up to `dir/` when a pathspec selects
522                // the directory *as a whole* (a directory-prefix that covers it, or
523                // a glob matching its name). It descends only when a pathspec
524                // targets something strictly below it that does not select the
525                // directory itself (e.g. a deeper glob like `dir/*.c` or an exact
526                // file path).
527                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            // A file reached here was found by descending into its parent
554            // directory, which happens only when that directory is not eligible
555            // for rollup (it contains tracked content, has ignored entries `-d`
556            // must preserve, or a pathspec selects something strictly below it).
557            // Git's `--directory` rollup is a directory-level decision made when
558            // the whole directory matches; an individually-reached file is always
559            // listed individually.
560            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    // The index map is sorted, so a single range query finds whether any tracked
571    // path lives under `directory/` in O(log n) — scanning every key was O(n) per
572    // untracked directory (quadratic over a deep untracked tree).
573    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
584/// Derives normal-mode untracked paths (directory rollup) from the worktree map
585/// produced by the single status walk, avoiding a third filesystem traversal.
586pub(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    /// How `pattern` should be matched against a slash-free segment. Most
2295    /// `.gitignore` entries are literals or simple `*.ext` / `prefix*` globs, all
2296    /// of which match without the allocating wildcard DP engine; only genuinely
2297    /// complex globs fall through to [`wildcard_path_matches`].
2298    pub(crate) match_kind: MatchKind,
2299    pub(crate) glob_literal_prefix_len: usize,
2300}
2301
2302/// Classification of an [`IgnorePattern`] that lets common shapes skip the
2303/// general wildcard matcher. Literal/prefix/suffix variants match a slash-free
2304/// segment; [`MatchKind::PathSuffix`] handles the common `**/literal/path`
2305/// shape, and the remaining complex patterns defer to the full engine.
2306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2307pub(crate) enum MatchKind {
2308    /// No metacharacters: matches by byte equality.
2309    Literal,
2310    /// `*X` with `X` literal: matches a segment ending in `X`.
2311    Suffix,
2312    /// `X*` with `X` literal: matches a segment starting with `X`.
2313    Prefix,
2314    /// `**/X/Y` with a literal suffix: matches a path ending at `X/Y`.
2315    PathSuffix,
2316    /// Anything else: defer to [`wildcard_path_matches`].
2317    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
2372/// Classify `pattern` for [`MatchKind`]. `*X`/`X*` fast paths require the literal
2373/// part to be slash-free so that `ends_with`/`starts_with` on a single segment is
2374/// exactly equivalent to the glob (`*` never crosses `/`).
2375pub(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    /// Builds only the repository-wide ignore sources — `core.excludesFile` (or the
2461    /// default global) and `$GIT_DIR/info/exclude` — *without* walking the worktree
2462    /// for `.gitignore`. The caller folds each directory's `.gitignore` into the
2463    /// matcher as it descends (see [`read_dir_ignore_patterns`]), so status reads
2464    /// the tree exactly once instead of doing a separate full-tree ignore pass.
2465    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}