Skip to main content

grit_lib/
untracked_cache.rs

1//! Git index UNTR (untracked cache) — `git/dir.c` / `read-cache.c`.
2#![allow(clippy::too_many_arguments)]
3
4use std::collections::BTreeSet;
5use std::ffi::CStr;
6use std::fs;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9
10#[cfg(unix)]
11use std::os::unix::fs::MetadataExt;
12
13use crate::config::{parse_path, ConfigSet};
14use crate::error::{Error, Result};
15use crate::ewah_bitmap::EwahBitmap;
16use crate::ignore::IgnoreMatcher;
17use crate::index::{Index, MODE_GITLINK};
18use crate::objects::{ObjectId, ObjectKind};
19use crate::odb::Odb;
20use crate::repo::Repository;
21
22pub const DIR_SHOW_OTHER_DIRECTORIES: u32 = 1 << 1;
23pub const DIR_HIDE_EMPTY_DIRECTORIES: u32 = 1 << 2;
24
25/// Git `struct stat_data` on disk (36 bytes).
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub struct StatDataDisk {
28    pub ctime_sec: u32,
29    pub ctime_nsec: u32,
30    pub mtime_sec: u32,
31    pub mtime_nsec: u32,
32    pub dev: u32,
33    pub ino: u32,
34    pub uid: u32,
35    pub gid: u32,
36    pub size: u32,
37}
38
39const STAT_DATA_LEN: usize = 36;
40
41impl StatDataDisk {
42    fn to_bytes(self) -> [u8; STAT_DATA_LEN] {
43        let mut out = [0u8; STAT_DATA_LEN];
44        out[0..4].copy_from_slice(&self.ctime_sec.to_be_bytes());
45        out[4..8].copy_from_slice(&self.ctime_nsec.to_be_bytes());
46        out[8..12].copy_from_slice(&self.mtime_sec.to_be_bytes());
47        out[12..16].copy_from_slice(&self.mtime_nsec.to_be_bytes());
48        out[16..20].copy_from_slice(&self.dev.to_be_bytes());
49        out[20..24].copy_from_slice(&self.ino.to_be_bytes());
50        out[24..28].copy_from_slice(&self.uid.to_be_bytes());
51        out[28..32].copy_from_slice(&self.gid.to_be_bytes());
52        out[32..36].copy_from_slice(&self.size.to_be_bytes());
53        out
54    }
55
56    fn from_bytes(b: &[u8]) -> Option<Self> {
57        if b.len() < STAT_DATA_LEN {
58            return None;
59        }
60        Some(Self {
61            ctime_sec: u32::from_be_bytes(b[0..4].try_into().ok()?),
62            ctime_nsec: u32::from_be_bytes(b[4..8].try_into().ok()?),
63            mtime_sec: u32::from_be_bytes(b[8..12].try_into().ok()?),
64            mtime_nsec: u32::from_be_bytes(b[12..16].try_into().ok()?),
65            dev: u32::from_be_bytes(b[16..20].try_into().ok()?),
66            ino: u32::from_be_bytes(b[20..24].try_into().ok()?),
67            uid: u32::from_be_bytes(b[24..28].try_into().ok()?),
68            gid: u32::from_be_bytes(b[28..32].try_into().ok()?),
69            size: u32::from_be_bytes(b[32..36].try_into().ok()?),
70        })
71    }
72}
73
74#[cfg(unix)]
75fn stat_data_from_meta(meta: &fs::Metadata) -> StatDataDisk {
76    StatDataDisk {
77        ctime_sec: meta.ctime() as u32,
78        ctime_nsec: meta.ctime_nsec() as u32,
79        mtime_sec: meta.mtime() as u32,
80        mtime_nsec: meta.mtime_nsec() as u32,
81        dev: meta.dev() as u32,
82        ino: meta.ino() as u32,
83        uid: meta.uid(),
84        gid: meta.gid(),
85        size: meta.len() as u32,
86    }
87}
88
89#[cfg(not(unix))]
90fn stat_data_from_meta(meta: &fs::Metadata) -> StatDataDisk {
91    StatDataDisk {
92        mtime_sec: meta
93            .modified()
94            .ok()
95            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
96            .map(|d| d.as_secs() as u32)
97            .unwrap_or(0),
98        size: meta.len() as u32,
99        ..Default::default()
100    }
101}
102
103#[derive(Clone, Debug)]
104pub struct OidStat {
105    pub stat: StatDataDisk,
106    pub oid: ObjectId,
107    pub valid: bool,
108}
109
110impl Default for OidStat {
111    fn default() -> Self {
112        Self {
113            stat: StatDataDisk::default(),
114            oid: ObjectId::zero(),
115            valid: false,
116        }
117    }
118}
119
120#[derive(Clone, Debug)]
121pub struct UntrackedCacheDir {
122    pub name: String,
123    pub untracked: Vec<String>,
124    pub dirs: Vec<UntrackedCacheDir>,
125    pub recurse: bool,
126    pub check_only: bool,
127    pub valid: bool,
128    pub exclude_oid: ObjectId,
129    pub stat_data: StatDataDisk,
130}
131
132impl UntrackedCacheDir {
133    fn new(name: String) -> Self {
134        Self {
135            name,
136            untracked: Vec::new(),
137            dirs: Vec::new(),
138            recurse: false,
139            check_only: false,
140            valid: false,
141            exclude_oid: ObjectId::zero(),
142            stat_data: StatDataDisk::default(),
143        }
144    }
145}
146
147#[derive(Clone, Debug)]
148pub struct UntrackedCache {
149    pub ident: Vec<u8>,
150    pub ss_info_exclude: OidStat,
151    pub ss_excludes_file: OidStat,
152    pub dir_flags: u32,
153    pub exclude_per_dir: String,
154    pub root: Option<UntrackedCacheDir>,
155    pub dir_created: u64,
156    pub gitignore_invalidated: u64,
157    pub dir_invalidated: u64,
158    pub dir_opened: u64,
159}
160
161impl UntrackedCache {
162    pub fn new_shell(dir_flags: u32, ident: Vec<u8>) -> Self {
163        Self {
164            ident,
165            ss_info_exclude: OidStat::default(),
166            ss_excludes_file: OidStat::default(),
167            dir_flags,
168            exclude_per_dir: ".gitignore".to_string(),
169            root: None,
170            dir_created: 0,
171            gitignore_invalidated: 0,
172            dir_invalidated: 0,
173            dir_opened: 0,
174        }
175    }
176
177    pub fn reset_stats(&mut self) {
178        self.dir_created = 0;
179        self.gitignore_invalidated = 0;
180        self.dir_invalidated = 0;
181        self.dir_opened = 0;
182    }
183}
184
185fn encode_varint(mut value: u64, buf: &mut Vec<u8>) {
186    let mut varint = [0u8; 16];
187    let mut pos = varint.len() - 1;
188    varint[pos] = (value & 127) as u8;
189    while {
190        value >>= 7;
191        value != 0
192    } {
193        pos -= 1;
194        value -= 1;
195        varint[pos] = 128 | ((value & 127) as u8);
196    }
197    buf.extend_from_slice(&varint[pos..]);
198}
199
200fn decode_varint(bytes: &[u8]) -> Option<(u64, usize)> {
201    if bytes.is_empty() {
202        return None;
203    }
204    let mut i = 0usize;
205    let mut c = bytes[i];
206    i += 1;
207    let mut val = (c & 127) as u64;
208    while c & 128 != 0 {
209        if i >= bytes.len() {
210            return None;
211        }
212        c = bytes[i];
213        i += 1;
214        val = ((val + 1) << 7) + (c & 127) as u64;
215    }
216    Some((val, i))
217}
218
219struct WriteDirCtx<'a> {
220    index: &'a mut usize,
221    valid: EwahBitmap,
222    check_only: EwahBitmap,
223    sha1_valid: EwahBitmap,
224    out: Vec<u8>,
225    sb_stat: Vec<u8>,
226    sb_sha1: Vec<u8>,
227}
228
229fn write_one_dir(ucd: &UntrackedCacheDir, wd: &mut WriteDirCtx<'_>) {
230    let i = *wd.index;
231    *wd.index += 1;
232
233    let mut ucd = ucd.clone();
234    if !ucd.valid {
235        ucd.untracked.clear();
236        ucd.check_only = false;
237    }
238
239    if ucd.check_only {
240        wd.check_only.set_bit_extend(i);
241    }
242    if ucd.valid {
243        wd.valid.set_bit_extend(i);
244        wd.sb_stat.extend_from_slice(&ucd.stat_data.to_bytes());
245    }
246    if !ucd.exclude_oid.is_zero() {
247        wd.sha1_valid.set_bit_extend(i);
248        wd.sb_sha1.extend_from_slice(ucd.exclude_oid.as_bytes());
249    }
250
251    ucd.untracked.sort();
252    encode_varint(ucd.untracked.len() as u64, &mut wd.out);
253
254    let recurse_count = ucd.dirs.iter().filter(|d| d.recurse).count() as u64;
255    encode_varint(recurse_count, &mut wd.out);
256
257    wd.out.extend_from_slice(ucd.name.as_bytes());
258    wd.out.push(0);
259
260    for n in &ucd.untracked {
261        wd.out.extend_from_slice(n.as_bytes());
262        wd.out.push(0);
263    }
264
265    let mut subdirs: Vec<_> = ucd.dirs.iter().filter(|d| d.recurse).collect();
266    subdirs.sort_by(|a, b| a.name.cmp(&b.name));
267    for d in subdirs {
268        write_one_dir(d, wd);
269    }
270}
271
272/// Serialize UNTR payload (extension body only, no signature header).
273pub fn write_untracked_extension(uc: &UntrackedCache) -> Vec<u8> {
274    let mut out = Vec::new();
275    encode_varint(uc.ident.len() as u64, &mut out);
276    out.extend_from_slice(&uc.ident);
277
278    let mut hdr = Vec::with_capacity(STAT_DATA_LEN * 2 + 4);
279    hdr.extend_from_slice(&uc.ss_info_exclude.stat.to_bytes());
280    hdr.extend_from_slice(&uc.ss_excludes_file.stat.to_bytes());
281    hdr.extend_from_slice(&uc.dir_flags.to_be_bytes());
282    out.extend_from_slice(&hdr);
283    out.extend_from_slice(uc.ss_info_exclude.oid.as_bytes());
284    out.extend_from_slice(uc.ss_excludes_file.oid.as_bytes());
285    out.extend_from_slice(uc.exclude_per_dir.as_bytes());
286    out.push(0);
287
288    let Some(root) = &uc.root else {
289        encode_varint(0, &mut out);
290        return out;
291    };
292
293    let mut wd = WriteDirCtx {
294        index: &mut 0,
295        valid: EwahBitmap::new(),
296        check_only: EwahBitmap::new(),
297        sha1_valid: EwahBitmap::new(),
298        out: Vec::new(),
299        sb_stat: Vec::new(),
300        sb_sha1: Vec::new(),
301    };
302    let mut sorted_root = root.clone();
303    sorted_root.untracked.sort();
304    sorted_root.dirs.sort_by(|a, b| a.name.cmp(&b.name));
305    write_one_dir(&sorted_root, &mut wd);
306
307    encode_varint(*wd.index as u64, &mut out);
308    out.append(&mut wd.out);
309
310    // Match Git `write_untracked_extension`: valid, check_only, sha1_valid (`dir.c`).
311    let mut tmp = Vec::new();
312    wd.valid.serialize(&mut tmp);
313    out.append(&mut tmp);
314    tmp.clear();
315    wd.check_only.serialize(&mut tmp);
316    out.append(&mut tmp);
317    tmp.clear();
318    wd.sha1_valid.serialize(&mut tmp);
319    out.append(&mut tmp);
320    out.append(&mut wd.sb_stat);
321    out.append(&mut wd.sb_sha1);
322    out.push(0);
323    out
324}
325
326/// Parse UNTR body (after 4-byte signature + 4-byte size).
327pub fn parse_untracked_extension(data: &[u8]) -> Option<UntrackedCache> {
328    if data.len() <= 1 || data[data.len() - 1] != 0 {
329        return None;
330    }
331    let end = data.len() - 1;
332    let data = &data[..end];
333
334    let (ident_len, c) = decode_varint(data)?;
335    let start = c;
336    if start + ident_len as usize > data.len() {
337        return None;
338    }
339    let ident = data[start..start + ident_len as usize].to_vec();
340    let mut pos = start + ident_len as usize;
341
342    const HDR: usize = STAT_DATA_LEN * 2 + 4;
343    if data.len() < pos + HDR + 40 {
344        return None;
345    }
346    let info_stat = StatDataDisk::from_bytes(&data[pos..])?;
347    pos += STAT_DATA_LEN;
348    let excl_stat = StatDataDisk::from_bytes(&data[pos..])?;
349    pos += STAT_DATA_LEN;
350    let dir_flags = u32::from_be_bytes(data[pos..pos + 4].try_into().ok()?);
351    pos += 4;
352    let oid_info = ObjectId::from_bytes(&data[pos..pos + 20]).ok()?;
353    pos += 20;
354    let oid_excl = ObjectId::from_bytes(&data[pos..pos + 20]).ok()?;
355    pos += 20;
356
357    let eos = data[pos..].iter().position(|&b| b == 0)?;
358    let exclude_per_dir = String::from_utf8(data[pos..pos + eos].to_vec()).ok()?;
359    pos += eos + 1;
360
361    let mut uc = UntrackedCache {
362        ident,
363        ss_info_exclude: OidStat {
364            stat: info_stat,
365            oid: oid_info,
366            valid: true,
367        },
368        ss_excludes_file: OidStat {
369            stat: excl_stat,
370            oid: oid_excl,
371            valid: true,
372        },
373        dir_flags,
374        exclude_per_dir,
375        root: None,
376        dir_created: 0,
377        gitignore_invalidated: 0,
378        dir_invalidated: 0,
379        dir_opened: 0,
380    };
381
382    if pos >= data.len() {
383        return Some(uc);
384    }
385    let (n_nodes, c) = decode_varint(&data[pos..])?;
386    pos += c;
387    if n_nodes == 0 {
388        return Some(uc);
389    }
390
391    fn read_one_dir(data: &[u8], pos: &mut usize) -> Option<UntrackedCacheDir> {
392        let (untracked_nr, c) = decode_varint(&data[*pos..])?;
393        *pos += c;
394        let (dirs_nr, c) = decode_varint(&data[*pos..])?;
395        *pos += c;
396        let untracked_nr = untracked_nr as usize;
397        let dirs_nr = dirs_nr as usize;
398
399        let name_start = *pos;
400        let name_end = name_start + data[name_start..].iter().position(|&b| b == 0)?;
401        let name = String::from_utf8(data[name_start..name_end].to_vec()).ok()?;
402        *pos = name_end + 1;
403
404        let mut untracked = Vec::with_capacity(untracked_nr);
405        for _ in 0..untracked_nr {
406            let s = *pos;
407            let e = s + data[s..].iter().position(|&b| b == 0)?;
408            untracked.push(String::from_utf8(data[s..e].to_vec()).ok()?);
409            *pos = e + 1;
410        }
411
412        let mut ucd = UntrackedCacheDir::new(name);
413        ucd.untracked = untracked;
414
415        for _ in 0..dirs_nr {
416            ucd.dirs.push(read_one_dir(data, pos)?);
417        }
418        Some(ucd)
419    }
420
421    let mut read_pos = pos;
422    let mut root = read_one_dir(data, &mut read_pos)?;
423
424    let rest = &data[read_pos..];
425    let (valid_bm, vlen) = EwahBitmap::deserialize_prefix(rest)?;
426    let rest = &rest[vlen..];
427    let (check_bm, clen) = EwahBitmap::deserialize_prefix(rest)?;
428    let rest = &rest[clen..];
429    let (sha_bm, slen) = EwahBitmap::deserialize_prefix(rest)?;
430    let rest = &rest[slen..];
431
432    let n = n_nodes as usize;
433    let mut check_bits = Vec::new();
434    check_bm.each_set_bit(|i| check_bits.push(i));
435    let mut valid_bits = Vec::new();
436    valid_bm.each_set_bit(|i| valid_bits.push(i));
437    let mut sha_bits = Vec::new();
438    sha_bm.each_set_bit(|i| sha_bits.push(i));
439
440    let stat_len = valid_bits.len() * STAT_DATA_LEN;
441    let oid_len = sha_bits.len() * 20;
442    if rest.len() < stat_len + oid_len {
443        return None;
444    }
445    let (stat_part, tail) = rest.split_at(stat_len);
446    let (oid_part, after_oids) = tail.split_at(oid_len);
447    if !after_oids.is_empty() {
448        return None;
449    }
450    let mut stat_slice = stat_part;
451    let mut oid_slice = oid_part;
452
453    fn apply(
454        u: &mut UntrackedCacheDir,
455        idx: &mut usize,
456        check: &[usize],
457        valid: &[usize],
458        sha: &[usize],
459        stat_bytes: &mut &[u8],
460        oid_bytes: &mut &[u8],
461    ) -> Option<()> {
462        let i = *idx;
463        *idx += 1;
464        u.recurse = true;
465        u.check_only = check.contains(&i);
466        if valid.contains(&i) {
467            u.valid = true;
468            if stat_bytes.len() < STAT_DATA_LEN {
469                return None;
470            }
471            u.stat_data = StatDataDisk::from_bytes(&stat_bytes[..STAT_DATA_LEN])?;
472            *stat_bytes = &stat_bytes[STAT_DATA_LEN..];
473        }
474        if sha.contains(&i) {
475            if oid_bytes.len() < 20 {
476                return None;
477            }
478            u.exclude_oid = ObjectId::from_bytes(&oid_bytes[..20]).ok()?;
479            *oid_bytes = &oid_bytes[20..];
480        }
481        u.dirs.sort_by(|a, b| a.name.cmp(&b.name));
482        for d in &mut u.dirs {
483            apply(d, idx, check, valid, sha, stat_bytes, oid_bytes)?;
484        }
485        Some(())
486    }
487
488    let mut idx = 0usize;
489    apply(
490        &mut root,
491        &mut idx,
492        &check_bits,
493        &valid_bits,
494        &sha_bits,
495        &mut stat_slice,
496        &mut oid_slice,
497    )?;
498    if idx != n {
499        return None;
500    }
501    uc.root = Some(root);
502    Some(uc)
503}
504
505pub fn untracked_cache_ident(work_tree: &Path) -> Vec<u8> {
506    #[cfg(unix)]
507    let sysname = {
508        let mut uts: libc::utsname = unsafe { std::mem::zeroed() };
509        unsafe {
510            if libc::uname(&mut uts) == 0 {
511                CStr::from_ptr(uts.sysname.as_ptr())
512                    .to_string_lossy()
513                    .into_owned()
514            } else {
515                "unknown".to_string()
516            }
517        }
518    };
519    #[cfg(not(unix))]
520    let sysname = "unknown".to_string();
521
522    let loc = work_tree.display().to_string();
523    let mut s = format!("Location {loc}, system {sysname}");
524    s.push('\0');
525    s.into_bytes()
526}
527
528pub fn dir_flags_from_config(config: &ConfigSet) -> u32 {
529    if config
530        .get("status.showUntrackedFiles")
531        .or_else(|| config.get("status.showuntrackedfiles"))
532        .is_some_and(|v| v.eq_ignore_ascii_case("all"))
533    {
534        0
535    } else {
536        DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES
537    }
538}
539
540fn global_excludes_path(repo: &Repository, config: &ConfigSet) -> Option<PathBuf> {
541    let raw = config
542        .get("core.excludesFile")
543        .or_else(|| config.get("core.excludesfile"))?;
544    let expanded = parse_path(&raw);
545    let p = Path::new(&expanded);
546    if p.is_absolute() {
547        Some(p.to_path_buf())
548    } else {
549        repo.work_tree.as_ref().map(|wt| wt.join(p))
550    }
551}
552
553fn file_stat_and_blob_oid(path: &Path) -> Result<(StatDataDisk, ObjectId)> {
554    match fs::metadata(path) {
555        Ok(meta) => {
556            let st = stat_data_from_meta(&meta);
557            let mut f = fs::File::open(path).map_err(Error::Io)?;
558            let mut buf = Vec::new();
559            f.read_to_end(&mut buf).map_err(Error::Io)?;
560            let oid = if buf.is_empty() {
561                Odb::hash_object_data(ObjectKind::Blob, &buf)
562            } else {
563                // Match Git's exclude-file oid normalization used by the untracked cache:
564                // parsed non-empty ignore files carry a trailing newline sentinel.
565                let mut normalized = buf;
566                normalized.push(b'\n');
567                Odb::hash_object_data(ObjectKind::Blob, &normalized)
568            };
569            Ok((st, oid))
570        }
571        Err(_) => Ok((StatDataDisk::default(), ObjectId::zero())),
572    }
573}
574
575fn do_invalidate_gitignore(dir: &mut UntrackedCacheDir) {
576    dir.valid = false;
577    dir.untracked.clear();
578    for d in &mut dir.dirs {
579        do_invalidate_gitignore(d);
580    }
581}
582
583fn invalidate_gitignore(uc: &mut UntrackedCache) {
584    if let Some(root) = uc.root.as_mut() {
585        do_invalidate_gitignore(root);
586    }
587}
588
589fn invalidate_directory(uc: &mut UntrackedCache, dir: &mut UntrackedCacheDir) {
590    if dir.valid {
591        uc.dir_invalidated += 1;
592    }
593    dir.valid = false;
594    dir.untracked.clear();
595    for d in &mut dir.dirs {
596        // Preserve collapsed placeholders across parent invalidation so their
597        // cache nodes remain available for dump-shape parity on the next scan.
598        d.recurse = d.check_only;
599    }
600}
601
602fn tracked_ignore_blob_oid(index: &Index, rel_path: &str) -> Option<ObjectId> {
603    let entry = index.get(rel_path.as_bytes(), 0)?;
604    if entry.mode == MODE_GITLINK {
605        return None;
606    }
607    Some(entry.oid)
608}
609
610fn invalidate_one_directory_for_path(uc: &mut UntrackedCache, dir: &mut UntrackedCacheDir) {
611    if dir.valid {
612        uc.dir_invalidated += 1;
613    }
614    dir.valid = false;
615    dir.untracked.clear();
616    for d in &mut dir.dirs {
617        if d.check_only {
618            d.recurse = true;
619        }
620    }
621}
622
623pub fn invalidate_path(uc: &mut UntrackedCache, path: &str) {
624    let Some(mut root) = uc.root.take() else {
625        return;
626    };
627    let _ = invalidate_one_component(uc, &mut root, path);
628    uc.root = Some(root);
629}
630
631fn invalidate_one_component(
632    uc: &mut UntrackedCache,
633    dir: &mut UntrackedCacheDir,
634    path: &str,
635) -> bool {
636    if let Some(slash) = path.find('/') {
637        let (comp, tail) = path.split_at(slash);
638        let tail = &tail[1..];
639        if let Some(d) = dir.dirs.iter_mut().find(|x| x.name == comp) {
640            let ret = invalidate_one_component(uc, d, tail);
641            if ret {
642                invalidate_one_directory_for_path(uc, dir);
643            }
644            ret
645        } else {
646            false
647        }
648    } else {
649        invalidate_one_directory_for_path(uc, dir);
650        uc.dir_flags & DIR_SHOW_OTHER_DIRECTORIES != 0
651    }
652}
653
654fn has_tracked_under(
655    tracked: &BTreeSet<String>,
656    gitlinks: &BTreeSet<String>,
657    rel_dir: &str,
658) -> bool {
659    let prefix = if rel_dir.is_empty() {
660        String::new()
661    } else {
662        format!("{rel_dir}/")
663    };
664    tracked
665        .range::<String, _>(prefix.clone()..)
666        .next()
667        .is_some_and(|t| t.starts_with(&prefix))
668        || gitlinks.iter().any(|g| {
669            g.as_str() == rel_dir || (!rel_dir.is_empty() && g.starts_with(&format!("{rel_dir}/")))
670        })
671}
672
673fn has_hidden_untracked_file_or_dir(
674    repo: &Repository,
675    index: &Index,
676    tracked: &BTreeSet<String>,
677    gitlinks: &BTreeSet<String>,
678    matcher: &mut IgnoreMatcher,
679    rel: &str,
680    abs: &Path,
681    uc: &mut UntrackedCache,
682) -> Result<bool> {
683    let entries = match fs::read_dir(abs) {
684        Ok(e) => {
685            uc.dir_opened += 1;
686            e
687        }
688        Err(_) => return Ok(false),
689    };
690    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
691    sorted.sort_by_key(|e| e.file_name());
692    for entry in sorted {
693        let name = entry.file_name().to_string_lossy().to_string();
694        if name == ".git" {
695            continue;
696        }
697        let path = entry.path();
698        let child_rel = relative_path(rel, &name);
699        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
700        if is_dir && gitlinks.contains(&child_rel) {
701            continue;
702        }
703        if tracked.contains(&child_rel) {
704            continue;
705        }
706        if is_dir {
707            if has_hidden_untracked_file_or_dir(
708                repo, index, tracked, gitlinks, matcher, &child_rel, &path, uc,
709            )? {
710                return Ok(true);
711            }
712        } else {
713            let (is_ign, _) = matcher.check_path(repo, Some(index), &child_rel, false)?;
714            if !is_ign && name.starts_with('.') {
715                return Ok(true);
716            }
717        }
718    }
719    Ok(false)
720}
721
722fn has_ignored_entry_or_dir(
723    repo: &Repository,
724    index: &Index,
725    tracked: &BTreeSet<String>,
726    gitlinks: &BTreeSet<String>,
727    matcher: &mut IgnoreMatcher,
728    rel: &str,
729    abs: &Path,
730    uc: &mut UntrackedCache,
731) -> Result<bool> {
732    if matcher.check_path(repo, Some(index), rel, true)?.0 {
733        return Ok(true);
734    }
735    let entries = match fs::read_dir(abs) {
736        Ok(e) => e,
737        Err(_) => return Ok(false),
738    };
739    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
740    sorted.sort_by_key(|e| e.file_name());
741    for entry in sorted {
742        let name = entry.file_name().to_string_lossy().to_string();
743        if name == ".git" {
744            continue;
745        }
746        let path = entry.path();
747        let child_rel = relative_path(rel, &name);
748        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
749        if is_dir && gitlinks.contains(&child_rel) {
750            continue;
751        }
752        if tracked.contains(&child_rel) {
753            continue;
754        }
755        if is_dir {
756            if has_ignored_entry_or_dir(
757                repo, index, tracked, gitlinks, matcher, &child_rel, &path, uc,
758            )? {
759                return Ok(true);
760            }
761        } else {
762            let (is_ign, _) = matcher.check_path(repo, Some(index), &child_rel, false)?;
763            if is_ign {
764                return Ok(true);
765            }
766        }
767    }
768    Ok(false)
769}
770
771fn relative_path(parent: &str, name: &str) -> String {
772    if parent.is_empty() {
773        name.to_string()
774    } else {
775        format!("{parent}/{name}")
776    }
777}
778
779#[derive(Clone, Copy, PartialEq, Eq)]
780pub enum UntrackedIgnoredMode {
781    No,
782    Traditional,
783    Matching,
784}
785
786fn fill_exclude_oids(
787    repo: &Repository,
788    _work_tree: &Path,
789    config: &ConfigSet,
790    uc: &mut UntrackedCache,
791) -> Result<()> {
792    let info_path = repo.git_dir.join("info/exclude");
793    let (st_i, oid_i) = file_stat_and_blob_oid(&info_path)?;
794    if uc.ss_info_exclude.valid
795        && (uc.ss_info_exclude.stat != st_i || uc.ss_info_exclude.oid != oid_i)
796    {
797        uc.gitignore_invalidated += 1;
798        invalidate_gitignore(uc);
799    }
800    uc.ss_info_exclude.stat = st_i;
801    uc.ss_info_exclude.oid = oid_i;
802    uc.ss_info_exclude.valid = true;
803
804    let (st_e, oid_e) = if let Some(p) = global_excludes_path(repo, config) {
805        file_stat_and_blob_oid(&p)?
806    } else {
807        (StatDataDisk::default(), ObjectId::zero())
808    };
809    if uc.ss_excludes_file.valid
810        && (uc.ss_excludes_file.stat != st_e || uc.ss_excludes_file.oid != oid_e)
811    {
812        uc.gitignore_invalidated += 1;
813        invalidate_gitignore(uc);
814    }
815    uc.ss_excludes_file.stat = st_e;
816    uc.ss_excludes_file.oid = oid_e;
817    uc.ss_excludes_file.valid = true;
818
819    Ok(())
820}
821
822fn lookup_or_create_child<'a>(
823    parent: &'a mut UntrackedCacheDir,
824    name: &str,
825    uc: &mut UntrackedCache,
826) -> &'a mut UntrackedCacheDir {
827    if let Some(i) = parent.dirs.iter().position(|d| d.name == name) {
828        return &mut parent.dirs[i];
829    }
830    uc.dir_created += 1;
831    parent.dirs.push(UntrackedCacheDir::new(name.to_string()));
832    let n = parent.dirs.len() - 1;
833    &mut parent.dirs[n]
834}
835
836fn valid_cached_dir(ucd: &UntrackedCacheDir, abs: &Path, check_only: bool) -> bool {
837    if !ucd.valid {
838        return false;
839    }
840    let meta = match fs::symlink_metadata(abs) {
841        Ok(m) => m,
842        Err(_) => return false,
843    };
844    stat_data_from_meta(&meta) == ucd.stat_data && ucd.check_only == check_only
845}
846
847enum DirSource {
848    Disk(fs::ReadDir),
849    Cache {
850        dir_idx: usize,
851        file_idx: usize,
852        child_dirs: Vec<UntrackedCacheDir>,
853        child_files: Vec<String>,
854    },
855}
856
857/// Refresh untracked cache tree and counters (for `git status`).
858pub fn refresh_untracked_cache_for_status(
859    repo: &Repository,
860    index: &Index,
861    work_tree: &Path,
862    config: &ConfigSet,
863    uc: &mut UntrackedCache,
864    show_all_untracked: bool,
865    ignored_mode: UntrackedIgnoredMode,
866) -> Result<()> {
867    uc.reset_stats();
868    let requested_flags = if show_all_untracked {
869        0u32
870    } else {
871        DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES
872    };
873
874    let mut mode_switched = false;
875    if uc.dir_flags != requested_flags && uc.dir_flags != dir_flags_from_config(config) {
876        *uc = UntrackedCache::new_shell(requested_flags, untracked_cache_ident(work_tree));
877        mode_switched = true;
878    }
879    uc.dir_flags = requested_flags;
880
881    fill_exclude_oids(repo, work_tree, config, uc)?;
882    if mode_switched {
883        uc.gitignore_invalidated += 1;
884    }
885
886    let tracked: BTreeSet<String> = index
887        .entries
888        .iter()
889        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
890        .collect();
891    let gitlinks: BTreeSet<String> = index
892        .entries
893        .iter()
894        .filter(|e| e.stage() == 0 && e.mode == MODE_GITLINK)
895        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
896        .collect();
897
898    let mut matcher = IgnoreMatcher::from_repository(repo)?;
899
900    if uc.root.is_none() {
901        uc.root = Some(UntrackedCacheDir::new(String::new()));
902    }
903    let mut root = uc
904        .root
905        .take()
906        .ok_or_else(|| Error::IndexError("no uc root".into()))?;
907
908    read_directory_recursive(
909        repo,
910        index,
911        work_tree,
912        &tracked,
913        &gitlinks,
914        &mut matcher,
915        ignored_mode,
916        show_all_untracked,
917        false,
918        &mut root,
919        "",
920        work_tree,
921        uc,
922    )?;
923
924    uc.root = Some(root);
925
926    Ok(())
927}
928
929/// Collect untracked paths from a populated untracked cache tree.
930///
931/// The returned paths are repository-relative and match the cache shape built by
932/// [`refresh_untracked_cache_for_status`], including collapsed `dir/` entries in
933/// normal untracked mode and fully expanded file paths in `-uall` mode.
934#[must_use]
935pub fn collect_untracked_from_cache(uc: &UntrackedCache) -> Vec<String> {
936    fn walk(dir: &UntrackedCacheDir, rel: &str, out: &mut Vec<String>) {
937        for name in &dir.untracked {
938            if rel.is_empty() {
939                out.push(name.clone());
940            } else {
941                out.push(format!("{rel}/{name}"));
942            }
943        }
944        let mut children: Vec<&UntrackedCacheDir> = dir
945            .dirs
946            .iter()
947            .filter(|d| d.recurse && !d.check_only)
948            .collect();
949        children.sort_by(|a, b| a.name.cmp(&b.name));
950        for child in children {
951            let child_rel = if rel.is_empty() {
952                child.name.clone()
953            } else {
954                format!("{rel}/{}", child.name)
955            };
956            walk(child, &child_rel, out);
957        }
958    }
959
960    let mut out = Vec::new();
961    if let Some(root) = uc.root.as_ref() {
962        walk(root, "", &mut out);
963    }
964    out.sort();
965    out
966}
967
968fn read_directory_recursive(
969    repo: &Repository,
970    index: &Index,
971    work_tree: &Path,
972    tracked: &BTreeSet<String>,
973    gitlinks: &BTreeSet<String>,
974    matcher: &mut IgnoreMatcher,
975    ignored_mode: UntrackedIgnoredMode,
976    show_all: bool,
977    check_only: bool,
978    ucd: &mut UntrackedCacheDir,
979    rel: &str,
980    abs: &Path,
981    uc: &mut UntrackedCache,
982) -> Result<()> {
983    let parent_exclude_rel = if rel.is_empty() {
984        ".gitignore".to_string()
985    } else {
986        format!("{rel}/.gitignore")
987    };
988    let parent_exclude_path = work_tree.join(&parent_exclude_rel);
989    let tracked_ignore_oid = tracked_ignore_blob_oid(index, &parent_exclude_rel);
990    let parent_exclude_oid = match fs::metadata(&parent_exclude_path) {
991        Ok(_) => {
992            if tracked_ignore_oid.is_some() {
993                ObjectId::zero()
994            } else {
995                file_stat_and_blob_oid(&parent_exclude_path)
996                    .map(|(_, oid)| oid)
997                    .unwrap_or_else(|_| ObjectId::zero())
998            }
999        }
1000        Err(_) => tracked_ignore_oid.unwrap_or_else(ObjectId::zero),
1001    };
1002    let parent_exclude_changed = parent_exclude_oid != ucd.exclude_oid;
1003    if ucd.valid && parent_exclude_changed {
1004        uc.dir_invalidated += 1;
1005        uc.gitignore_invalidated += 1;
1006        do_invalidate_gitignore(ucd);
1007    }
1008
1009    let use_disk = !valid_cached_dir(ucd, abs, check_only);
1010    let mut src = if use_disk {
1011        invalidate_directory(uc, ucd);
1012        uc.dir_opened += 1;
1013        let p = if abs == work_tree && rel.is_empty() {
1014            work_tree.to_path_buf()
1015        } else {
1016            abs.to_path_buf()
1017        };
1018        DirSource::Disk(fs::read_dir(&p).map_err(Error::Io)?)
1019    } else {
1020        let mut child_dirs: Vec<_> = ucd
1021            .dirs
1022            .iter()
1023            .filter(|d| d.recurse && !d.check_only)
1024            .cloned()
1025            .collect();
1026        child_dirs.sort_by(|a, b| a.name.cmp(&b.name));
1027        let mut child_files = ucd.untracked.clone();
1028        child_files.sort();
1029        DirSource::Cache {
1030            dir_idx: 0,
1031            file_idx: 0,
1032            child_dirs,
1033            child_files,
1034        }
1035    };
1036
1037    ucd.check_only = check_only;
1038
1039    loop {
1040        let next = match &mut src {
1041            DirSource::Disk(rd) => {
1042                let Some(Ok(entry)) = rd.next() else {
1043                    break;
1044                };
1045                let name = entry.file_name().to_string_lossy().into_owned();
1046                if name == ".git" {
1047                    continue;
1048                }
1049                let path = entry.path();
1050                let is_dir = entry.file_type().map_err(Error::Io)?.is_dir();
1051                Some((name, path, is_dir))
1052            }
1053            DirSource::Cache {
1054                dir_idx,
1055                file_idx,
1056                child_dirs,
1057                child_files,
1058            } => {
1059                while *dir_idx < child_dirs.len() && !child_dirs[*dir_idx].recurse {
1060                    *dir_idx += 1;
1061                }
1062                if *dir_idx < child_dirs.len() {
1063                    let d = &child_dirs[*dir_idx];
1064                    *dir_idx += 1;
1065                    let child_abs = if rel.is_empty() {
1066                        work_tree.join(&d.name)
1067                    } else {
1068                        work_tree.join(rel).join(&d.name)
1069                    };
1070                    Some((d.name.clone(), child_abs, true))
1071                } else if *file_idx < child_files.len() {
1072                    let n = child_files[*file_idx].clone();
1073                    *file_idx += 1;
1074                    // Collapsed directory markers (`dir/`) are already represented in
1075                    // `ucd.untracked`. Re-traversing them via cache source would treat them as
1076                    // real directories and duplicate entries across successive status runs.
1077                    if n.ends_with('/') {
1078                        continue;
1079                    }
1080                    let child_rel = if rel.is_empty() {
1081                        n.clone()
1082                    } else {
1083                        format!("{rel}/{n}")
1084                    };
1085                    let child_abs = work_tree.join(&child_rel);
1086                    let is_dir = child_abs.is_dir();
1087                    let base = Path::new(&n)
1088                        .file_name()
1089                        .and_then(|s| s.to_str())
1090                        .unwrap_or(&n)
1091                        .to_string();
1092                    Some((base, child_abs, is_dir))
1093                } else {
1094                    break;
1095                }
1096            }
1097        };
1098
1099        let Some((name, path, is_dir)) = next else {
1100            continue;
1101        };
1102        let child_rel = relative_path(rel, &name);
1103
1104        if is_dir && gitlinks.contains(&child_rel) {
1105            continue;
1106        }
1107        if tracked.contains(&child_rel) {
1108            continue;
1109        }
1110
1111        if is_dir {
1112            visit_untracked_directory_uc(
1113                repo,
1114                index,
1115                work_tree,
1116                tracked,
1117                gitlinks,
1118                matcher,
1119                ignored_mode,
1120                show_all,
1121                ucd,
1122                &child_rel,
1123                &path,
1124                uc,
1125            )?;
1126        } else {
1127            let (is_ign, _) = matcher.check_path(repo, Some(index), &child_rel, false)?;
1128            if is_ign {
1129                continue;
1130            }
1131            if use_disk {
1132                ucd.untracked.push(name);
1133            }
1134        }
1135    }
1136
1137    if use_disk {
1138        ucd.dirs.retain(|d| d.recurse);
1139        ucd.dirs.sort_by(|a, b| a.name.cmp(&b.name));
1140    }
1141
1142    let meta = fs::symlink_metadata(abs).map_err(Error::Io)?;
1143    ucd.stat_data = stat_data_from_meta(&meta);
1144    if use_disk
1145        && (rel.is_empty() || !ucd.untracked.is_empty() || ucd.dirs.iter().any(|d| d.recurse))
1146    {
1147        ucd.exclude_oid = parent_exclude_oid;
1148    }
1149    ucd.valid = true;
1150    // Match Git's in-memory read_directory behavior: `check_only` directories are kept in
1151    // the UNTR tree but are not recursively traversed on subsequent status runs.
1152    if !check_only {
1153        ucd.recurse = true;
1154    }
1155
1156    Ok(())
1157}
1158
1159fn visit_untracked_directory_uc(
1160    repo: &Repository,
1161    index: &Index,
1162    work_tree: &Path,
1163    tracked: &BTreeSet<String>,
1164    gitlinks: &BTreeSet<String>,
1165    matcher: &mut IgnoreMatcher,
1166    ignored_mode: UntrackedIgnoredMode,
1167    show_all: bool,
1168    parent_ucd: &mut UntrackedCacheDir,
1169    rel: &str,
1170    abs: &Path,
1171    uc: &mut UntrackedCache,
1172) -> Result<()> {
1173    let name = Path::new(rel)
1174        .file_name()
1175        .and_then(|s| s.to_str())
1176        .unwrap_or(rel)
1177        .to_string();
1178
1179    if has_tracked_under(tracked, gitlinks, rel) {
1180        let child = lookup_or_create_child(parent_ucd, &name, uc);
1181        return read_directory_recursive(
1182            repo,
1183            index,
1184            work_tree,
1185            tracked,
1186            gitlinks,
1187            matcher,
1188            ignored_mode,
1189            show_all,
1190            false,
1191            child,
1192            rel,
1193            abs,
1194            uc,
1195        );
1196    }
1197
1198    // Fast prune for default ignored mode: an excluded directory cannot surface untracked
1199    // entries unless tracked descendants exist (handled above).
1200    if ignored_mode == UntrackedIgnoredMode::No
1201        && matcher.check_path(repo, Some(index), rel, true)?.0
1202    {
1203        return Ok(());
1204    }
1205
1206    if ignored_mode == UntrackedIgnoredMode::Matching
1207        && show_all
1208        && matcher.check_path(repo, Some(index), rel, true)?.0
1209    {
1210        return Ok(());
1211    }
1212
1213    if ignored_mode == UntrackedIgnoredMode::Traditional && !show_all {
1214        if let Some(line) = traditional_normal_directory_only(
1215            repo, index, work_tree, tracked, gitlinks, matcher, rel, abs, uc,
1216        )? {
1217            let _ = line;
1218            return Ok(());
1219        }
1220    }
1221
1222    if show_all {
1223        let child = lookup_or_create_child(parent_ucd, &name, uc);
1224        return read_directory_recursive(
1225            repo,
1226            index,
1227            work_tree,
1228            tracked,
1229            gitlinks,
1230            matcher,
1231            ignored_mode,
1232            true,
1233            false,
1234            child,
1235            rel,
1236            abs,
1237            uc,
1238        );
1239    }
1240
1241    if !show_all {
1242        let reuse_collapsed_index = parent_ucd
1243            .dirs
1244            .iter()
1245            .find(|d| d.name == name && d.check_only)
1246            .and_then(|target| parent_ucd.dirs.iter().position(|d| std::ptr::eq(d, target)))
1247            .filter(|&idx| valid_cached_dir(&parent_ucd.dirs[idx], abs, true));
1248        if let Some(idx) = reuse_collapsed_index {
1249            let candidate = &parent_ucd.dirs[idx];
1250            let has_visible =
1251                check_only_tree_has_visible_untracked(repo, index, matcher, rel, candidate)?;
1252            parent_ucd.dirs[idx].recurse = true;
1253            if has_visible {
1254                let collapsed = format!("{name}/");
1255                if !parent_ucd.untracked.iter().any(|u| u == &collapsed) {
1256                    parent_ucd.untracked.push(collapsed);
1257                }
1258            }
1259            return Ok(());
1260        }
1261    }
1262
1263    let mut sub_untracked = Vec::new();
1264    let mut sub_ignored = Vec::new();
1265    visit_untracked_node_full(
1266        repo,
1267        index,
1268        work_tree,
1269        tracked,
1270        gitlinks,
1271        matcher,
1272        ignored_mode,
1273        true,
1274        rel,
1275        abs,
1276        &mut sub_untracked,
1277        &mut sub_ignored,
1278        uc,
1279    )?;
1280
1281    if !sub_untracked.is_empty() && !sub_ignored.is_empty() {
1282        let child = lookup_or_create_child(parent_ucd, &name, uc);
1283        return read_directory_recursive(
1284            repo,
1285            index,
1286            work_tree,
1287            tracked,
1288            gitlinks,
1289            matcher,
1290            ignored_mode,
1291            true,
1292            false,
1293            child,
1294            rel,
1295            abs,
1296            uc,
1297        );
1298    }
1299
1300    if sub_untracked.is_empty() && !sub_ignored.is_empty() {
1301        let has_hidden = has_hidden_untracked_file_or_dir(
1302            repo, index, tracked, gitlinks, matcher, rel, abs, uc,
1303        )?;
1304        if has_hidden {
1305            let child = lookup_or_create_child(parent_ucd, &name, uc);
1306            child.recurse = true;
1307            child.check_only = true;
1308            child.valid = true;
1309            child.untracked.clear();
1310            child.dirs.clear();
1311            child.exclude_oid = ObjectId::zero();
1312            if let Ok(meta) = fs::symlink_metadata(abs) {
1313                child.stat_data = stat_data_from_meta(&meta);
1314            }
1315        } else if let Some(child) = parent_ucd
1316            .dirs
1317            .iter_mut()
1318            .find(|d| d.name == name && d.check_only)
1319        {
1320            // Keep existing placeholders reusable, but do not create new ones for
1321            // newly fully-ignored directories (t7063 sparse keep/true cache shape).
1322            child.recurse = true;
1323            child.check_only = true;
1324            child.valid = true;
1325            child.untracked.clear();
1326            child.dirs.clear();
1327            child.exclude_oid = ObjectId::zero();
1328            if let Ok(meta) = fs::symlink_metadata(abs) {
1329                child.stat_data = stat_data_from_meta(&meta);
1330            }
1331        }
1332        return Ok(());
1333    }
1334
1335    if sub_untracked.is_empty() && sub_ignored.is_empty() {
1336        if has_ignored_entry_or_dir(repo, index, tracked, gitlinks, matcher, rel, abs, uc)? {
1337            let child = lookup_or_create_child(parent_ucd, &name, uc);
1338            child.recurse = true;
1339            child.check_only = true;
1340            child.valid = true;
1341            child.untracked.clear();
1342            child.dirs.clear();
1343            child.exclude_oid = ObjectId::zero();
1344            if let Ok(meta) = fs::symlink_metadata(abs) {
1345                child.stat_data = stat_data_from_meta(&meta);
1346            }
1347            return Ok(());
1348        }
1349        if let Some(child) = parent_ucd
1350            .dirs
1351            .iter_mut()
1352            .find(|d| d.name == name && d.check_only)
1353        {
1354            // Preserve existing placeholder nodes for directories that now contain no
1355            // untracked entries but are part of an already-materialized check-only subtree.
1356            child.recurse = true;
1357            child.valid = true;
1358            child.untracked.clear();
1359            child.dirs.clear();
1360            child.exclude_oid = ObjectId::zero();
1361            if let Ok(meta) = fs::symlink_metadata(abs) {
1362                child.stat_data = stat_data_from_meta(&meta);
1363            }
1364        }
1365        return Ok(());
1366    }
1367
1368    if !sub_untracked.is_empty() && sub_ignored.is_empty() {
1369        // Git `lookup_untracked` allocates a child node even when the visible output collapses
1370        // the directory to `name/` in normal untracked mode (t7063 dump expectations).
1371        // Build that child in check-only mode from the already discovered full walk to avoid
1372        // reopening directories and overcounting `opendir` trace stats.
1373        let child = lookup_or_create_child(parent_ucd, &name, uc);
1374        populate_check_only_subtree(child, rel, abs, &sub_untracked, uc);
1375        let collapsed = format!("{name}/");
1376        if !parent_ucd.untracked.iter().any(|u| u == &collapsed) {
1377            parent_ucd.untracked.push(collapsed);
1378        }
1379        return Ok(());
1380    }
1381
1382    Ok(())
1383}
1384
1385fn populate_check_only_subtree(
1386    root: &mut UntrackedCacheDir,
1387    rel: &str,
1388    abs: &Path,
1389    sub_untracked: &[String],
1390    uc: &mut UntrackedCache,
1391) {
1392    root.untracked.clear();
1393    root.dirs.clear();
1394    // Keep check-only directories in UNTR output shape (Git writes them with `recurse` set),
1395    // but runtime scans skip them via `!d.check_only` in cache traversal.
1396    root.recurse = true;
1397    root.check_only = true;
1398    root.valid = true;
1399    root.exclude_oid = ObjectId::zero();
1400    if let Ok(meta) = fs::symlink_metadata(abs) {
1401        root.stat_data = stat_data_from_meta(&meta);
1402    }
1403
1404    let prefix = if rel.is_empty() {
1405        String::new()
1406    } else {
1407        format!("{rel}/")
1408    };
1409    for full in sub_untracked {
1410        let rest = if prefix.is_empty() {
1411            full.as_str()
1412        } else if let Some(stripped) = full.strip_prefix(&prefix) {
1413            stripped
1414        } else {
1415            continue;
1416        };
1417        if rest.is_empty() {
1418            continue;
1419        }
1420        let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect();
1421        if parts.is_empty() {
1422            continue;
1423        }
1424        insert_check_only_path(root, abs, &parts, uc);
1425    }
1426    sort_untracked_tree(root);
1427}
1428
1429fn insert_check_only_path(
1430    dir: &mut UntrackedCacheDir,
1431    dir_abs: &Path,
1432    parts: &[&str],
1433    uc: &mut UntrackedCache,
1434) {
1435    if parts.is_empty() {
1436        return;
1437    }
1438    if parts.len() == 1 {
1439        let file = parts[0].to_string();
1440        if !dir.untracked.iter().any(|u| u == &file) {
1441            dir.untracked.push(file);
1442        }
1443        return;
1444    }
1445
1446    let comp = parts[0];
1447    let collapsed = format!("{comp}/");
1448    if !dir.untracked.iter().any(|u| u == &collapsed) {
1449        dir.untracked.push(collapsed);
1450    }
1451    let child_abs = dir_abs.join(comp);
1452    let child = lookup_or_create_child(dir, comp, uc);
1453    child.recurse = true;
1454    child.check_only = true;
1455    child.valid = true;
1456    child.exclude_oid = ObjectId::zero();
1457    if let Ok(meta) = fs::symlink_metadata(&child_abs) {
1458        child.stat_data = stat_data_from_meta(&meta);
1459    }
1460    insert_check_only_path(child, &child_abs, &parts[1..], uc);
1461}
1462
1463fn sort_untracked_tree(dir: &mut UntrackedCacheDir) {
1464    dir.untracked.sort();
1465    dir.untracked.dedup();
1466    dir.dirs.sort_by(|a, b| a.name.cmp(&b.name));
1467    for child in &mut dir.dirs {
1468        sort_untracked_tree(child);
1469    }
1470}
1471
1472fn check_only_tree_has_visible_untracked(
1473    repo: &Repository,
1474    index: &Index,
1475    matcher: &mut IgnoreMatcher,
1476    rel: &str,
1477    dir: &UntrackedCacheDir,
1478) -> Result<bool> {
1479    let prefix = if rel.is_empty() {
1480        String::new()
1481    } else {
1482        format!("{rel}/")
1483    };
1484
1485    for file in &dir.untracked {
1486        let path = format!("{prefix}{file}");
1487        let (is_ignored, _) = matcher.check_path(repo, Some(index), &path, false)?;
1488        if !is_ignored {
1489            return Ok(true);
1490        }
1491    }
1492
1493    for child in &dir.dirs {
1494        let child_rel = if rel.is_empty() {
1495            child.name.clone()
1496        } else {
1497            format!("{rel}/{}", child.name)
1498        };
1499        if check_only_tree_has_visible_untracked(repo, index, matcher, &child_rel, child)? {
1500            return Ok(true);
1501        }
1502    }
1503
1504    Ok(false)
1505}
1506
1507fn visit_untracked_node_full(
1508    repo: &Repository,
1509    index: &Index,
1510    work_tree: &Path,
1511    tracked: &BTreeSet<String>,
1512    gitlinks: &BTreeSet<String>,
1513    matcher: &mut IgnoreMatcher,
1514    ignored_mode: UntrackedIgnoredMode,
1515    show_all: bool,
1516    rel: &str,
1517    abs: &Path,
1518    untracked_out: &mut Vec<String>,
1519    ignored_out: &mut Vec<String>,
1520    uc: &mut UntrackedCache,
1521) -> Result<()> {
1522    let entries = match fs::read_dir(abs) {
1523        Ok(e) => {
1524            uc.dir_opened += 1;
1525            e
1526        }
1527        Err(_) => return Ok(()),
1528    };
1529    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
1530    sorted.sort_by_key(|e| e.file_name());
1531
1532    for entry in sorted {
1533        let name = entry.file_name().to_string_lossy().to_string();
1534        if name == ".git" {
1535            continue;
1536        }
1537        let path = entry.path();
1538        let child_rel = relative_path(rel, &name);
1539        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
1540
1541        if is_dir && gitlinks.contains(&child_rel) {
1542            continue;
1543        }
1544        if tracked.contains(&child_rel) {
1545            continue;
1546        }
1547
1548        if is_dir {
1549            visit_untracked_directory_collect(
1550                repo,
1551                index,
1552                work_tree,
1553                tracked,
1554                gitlinks,
1555                matcher,
1556                ignored_mode,
1557                show_all,
1558                &child_rel,
1559                &path,
1560                untracked_out,
1561                ignored_out,
1562                uc,
1563            )?;
1564        } else {
1565            let (is_ign, _) = matcher.check_path(repo, Some(index), &child_rel, false)?;
1566            if is_ign {
1567                if ignored_mode != UntrackedIgnoredMode::No {
1568                    ignored_out.push(child_rel);
1569                }
1570            } else {
1571                untracked_out.push(child_rel);
1572            }
1573        }
1574    }
1575    Ok(())
1576}
1577
1578fn visit_untracked_directory_collect(
1579    repo: &Repository,
1580    index: &Index,
1581    work_tree: &Path,
1582    tracked: &BTreeSet<String>,
1583    gitlinks: &BTreeSet<String>,
1584    matcher: &mut IgnoreMatcher,
1585    ignored_mode: UntrackedIgnoredMode,
1586    show_all: bool,
1587    rel: &str,
1588    abs: &Path,
1589    untracked_out: &mut Vec<String>,
1590    ignored_out: &mut Vec<String>,
1591    uc: &mut UntrackedCache,
1592) -> Result<()> {
1593    if has_tracked_under(tracked, gitlinks, rel) {
1594        return visit_untracked_node_full(
1595            repo,
1596            index,
1597            work_tree,
1598            tracked,
1599            gitlinks,
1600            matcher,
1601            ignored_mode,
1602            show_all,
1603            rel,
1604            abs,
1605            untracked_out,
1606            ignored_out,
1607            uc,
1608        );
1609    }
1610
1611    // Fast prune for default ignored mode: excluded directories cannot contribute visible
1612    // untracked entries when there are no tracked descendants.
1613    if ignored_mode == UntrackedIgnoredMode::No
1614        && matcher.check_path(repo, Some(index), rel, true)?.0
1615    {
1616        return Ok(());
1617    }
1618
1619    if ignored_mode == UntrackedIgnoredMode::Matching
1620        && show_all
1621        && matcher.check_path(repo, Some(index), rel, true)?.0
1622    {
1623        ignored_out.push(format!("{rel}/"));
1624        return Ok(());
1625    }
1626
1627    if ignored_mode == UntrackedIgnoredMode::Traditional && !show_all {
1628        if let Some(line) = traditional_normal_directory_only(
1629            repo, index, work_tree, tracked, gitlinks, matcher, rel, abs, uc,
1630        )? {
1631            ignored_out.push(line);
1632            return Ok(());
1633        }
1634    }
1635
1636    let mut sub_u = Vec::new();
1637    let mut sub_i = Vec::new();
1638    visit_untracked_node_full(
1639        repo,
1640        index,
1641        work_tree,
1642        tracked,
1643        gitlinks,
1644        matcher,
1645        ignored_mode,
1646        true,
1647        rel,
1648        abs,
1649        &mut sub_u,
1650        &mut sub_i,
1651        uc,
1652    )?;
1653
1654    if show_all {
1655        untracked_out.append(&mut sub_u);
1656        ignored_out.append(&mut sub_i);
1657        return Ok(());
1658    }
1659
1660    if !sub_u.is_empty() && !sub_i.is_empty() {
1661        untracked_out.append(&mut sub_u);
1662        ignored_out.append(&mut sub_i);
1663        return Ok(());
1664    }
1665
1666    if sub_u.is_empty() && !sub_i.is_empty() {
1667        let dir_excluded = matcher.check_path(repo, Some(index), rel, true)?.0;
1668        let collapse_matching = ignored_mode == UntrackedIgnoredMode::Matching && dir_excluded;
1669        let collapse_traditional = ignored_mode == UntrackedIgnoredMode::Traditional;
1670        if collapse_matching || collapse_traditional {
1671            ignored_out.push(format!("{rel}/"));
1672        } else {
1673            ignored_out.append(&mut sub_i);
1674        }
1675        return Ok(());
1676    }
1677
1678    if !sub_u.is_empty() && sub_i.is_empty() {
1679        if rel.is_empty() {
1680            untracked_out.append(&mut sub_u);
1681        } else {
1682            untracked_out.push(format!("{rel}/"));
1683        }
1684    }
1685
1686    Ok(())
1687}
1688
1689fn traditional_normal_directory_only(
1690    repo: &Repository,
1691    index: &Index,
1692    work_tree: &Path,
1693    tracked: &BTreeSet<String>,
1694    gitlinks: &BTreeSet<String>,
1695    matcher: &mut IgnoreMatcher,
1696    rel: &str,
1697    abs: &Path,
1698    uc: &mut UntrackedCache,
1699) -> Result<Option<String>> {
1700    let mut any_file = false;
1701    let mut stack = vec![abs.to_path_buf()];
1702    while let Some(dir) = stack.pop() {
1703        let entries = match fs::read_dir(&dir) {
1704            Ok(e) => {
1705                uc.dir_opened += 1;
1706                e
1707            }
1708            Err(_) => continue,
1709        };
1710        let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
1711        sorted.sort_by_key(|e| e.file_name());
1712        for entry in sorted {
1713            let name = entry.file_name().to_string_lossy().to_string();
1714            if name == ".git" {
1715                continue;
1716            }
1717            let path = entry.path();
1718            let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
1719            let rel_child = if dir == *abs {
1720                relative_path(rel, &name)
1721            } else {
1722                let suffix = path.strip_prefix(work_tree).unwrap_or(&path);
1723                suffix.to_string_lossy().replace('\\', "/")
1724            };
1725            if is_dir && gitlinks.contains(&rel_child) {
1726                continue;
1727            }
1728            if tracked.contains(&rel_child) {
1729                return Ok(None);
1730            }
1731            if is_dir {
1732                stack.push(path);
1733            } else {
1734                any_file = true;
1735                let (ig, _) = matcher.check_path(repo, Some(index), &rel_child, false)?;
1736                if !ig {
1737                    return Ok(None);
1738                }
1739            }
1740        }
1741    }
1742    if any_file {
1743        Ok(Some(format!("{rel}/")))
1744    } else {
1745        Ok(None)
1746    }
1747}
1748
1749#[cfg(test)]
1750mod tests {
1751    use super::*;
1752
1753    #[test]
1754    fn untracked_extension_round_trip_shell() {
1755        let uc = UntrackedCache::new_shell(6, b"ident\x00".to_vec());
1756        let raw = write_untracked_extension(&uc);
1757        let back = parse_untracked_extension(&raw).expect("parse shell");
1758        assert_eq!(back.dir_flags, 6);
1759        assert_eq!(back.ident, uc.ident);
1760        assert!(back.root.is_none());
1761    }
1762
1763    #[test]
1764    fn untracked_extension_round_trip_with_tree() {
1765        let mut uc = UntrackedCache::new_shell(6, b"id\x00".to_vec());
1766        let mut root = UntrackedCacheDir::new(String::new());
1767        root.valid = true;
1768        root.recurse = true;
1769        root.stat_data = StatDataDisk {
1770            mtime_sec: 1,
1771            ..Default::default()
1772        };
1773        root.untracked = vec!["a".to_string(), "b".to_string()];
1774        let mut child = UntrackedCacheDir::new("sub".to_string());
1775        child.valid = true;
1776        child.recurse = true;
1777        root.dirs.push(child);
1778        uc.root = Some(root);
1779
1780        let raw = write_untracked_extension(&uc);
1781        let back = parse_untracked_extension(&raw).expect("parse tree");
1782        assert!(back.root.is_some());
1783        let r = back.root.as_ref().unwrap();
1784        assert_eq!(r.untracked.len(), 2);
1785        assert_eq!(r.dirs.len(), 1);
1786    }
1787}