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