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