1#![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#[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
271pub 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 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
325pub 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 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 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
848pub 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#[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 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 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 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 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 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 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 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 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}