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