1use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10
11use gravityfile_core::GitStatus;
12use tokio_util::sync::CancellationToken;
13
14#[derive(Debug, Default)]
16pub struct GitStatusCache {
17 statuses: HashMap<PathBuf, GitStatus>,
19 ignored_directories: HashSet<PathBuf>,
21 clean_tracked: HashSet<PathBuf>,
23 scope_root: Option<PathBuf>,
24 repo_root: Option<PathBuf>,
26 initialization_error: Option<String>,
28}
29
30impl GitStatusCache {
31 pub fn new() -> Self {
33 Self::default()
34 }
35
36 #[cfg(feature = "git")]
46 pub fn initialize(&mut self, start_path: &Path) -> bool {
47 self.initialize_cancellable(start_path, &CancellationToken::new())
48 }
49
50 #[cfg(feature = "git")]
51 fn initialize_cancellable(&mut self, start_path: &Path, token: &CancellationToken) -> bool {
52 use git2::{Repository, StatusOptions};
53
54 self.statuses = HashMap::new();
59 self.ignored_directories = HashSet::new();
60 self.clean_tracked = HashSet::new();
61 self.scope_root = None;
62 self.repo_root = None;
63 self.initialization_error = None;
64 if token.is_cancelled() {
65 return false;
66 }
67
68 let repo = match Repository::discover(start_path) {
70 Ok(repo) => repo,
71 Err(error) => {
72 if error.code() != git2::ErrorCode::NotFound {
73 self.initialization_error = Some(error.to_string());
74 }
75 return false;
76 }
77 };
78
79 let workdir = match repo.workdir().map(dunce::canonicalize) {
83 Some(Ok(dir)) => dir,
84 Some(Err(error)) => {
85 self.initialization_error = Some(error.to_string());
86 return false;
87 }
88 None => return false, };
90 let normalized_start = match dunce::canonicalize(start_path) {
91 Ok(path) => path,
92 Err(error) => {
93 self.initialization_error = Some(error.to_string());
94 return false;
95 }
96 };
97 let relative = match normalized_start.strip_prefix(&workdir) {
98 Ok(path) => path,
99 Err(error) => {
100 self.initialization_error = Some(error.to_string());
101 return false;
102 }
103 };
104 self.repo_root = Some(workdir.clone());
105 self.scope_root = Some(normalized_start.clone());
106
107 let mut opts = StatusOptions::new();
111 opts.include_untracked(true)
112 .include_ignored(true)
113 .recurse_ignored_dirs(false)
114 .recurse_untracked_dirs(true)
115 .include_unmodified(true)
118 .disable_pathspec_match(true);
119 if !relative.as_os_str().is_empty() {
120 opts.pathspec(relative);
121 }
122
123 if token.is_cancelled() {
124 return false;
125 }
126 let statuses = match repo.statuses(Some(&mut opts)) {
127 Ok(s) => s,
128 Err(error) => {
129 self.initialization_error = Some(error.to_string());
130 return true;
131 }
132 };
133
134 for entry in statuses.iter() {
136 if token.is_cancelled() {
137 self.statuses = HashMap::new();
138 self.ignored_directories = HashSet::new();
139 self.clean_tracked = HashSet::new();
140 return false;
141 }
142 let status = entry.status();
143 #[cfg(unix)]
144 let path = {
145 use std::os::unix::ffi::OsStrExt;
146 workdir.join(std::ffi::OsStr::from_bytes(entry.path_bytes()))
147 };
148 #[cfg(not(unix))]
149 let path = match self.utf8_status_path(&workdir, entry.path_bytes()) {
150 Some(path) => path,
151 None => return true, };
153
154 let git_status = if status.is_conflicted() {
155 GitStatus::Conflict
156 } else if status.is_index_new()
157 || status.is_index_modified()
158 || status.is_index_deleted()
159 || status.is_index_renamed()
160 || status.is_index_typechange()
161 {
162 GitStatus::Staged
163 } else if status.is_wt_new() {
164 GitStatus::Untracked
165 } else if status.is_wt_modified()
166 || status.is_wt_deleted()
167 || status.is_wt_renamed()
168 || status.is_wt_typechange()
169 {
170 GitStatus::Modified
171 } else if status.is_ignored() {
172 GitStatus::Ignored
173 } else {
174 self.clean_tracked.insert(path);
175 continue; };
177
178 if git_status == GitStatus::Ignored && entry.path_bytes().last() == Some(&b'/') {
179 self.ignored_directories.insert(path.clone());
180 }
181 self.statuses.insert(path, git_status);
182 }
183
184 self.compact_clean_tracked(&|| token.is_cancelled())
185 }
186
187 #[cfg(any(feature = "git", test))]
188 fn compact_clean_tracked(&mut self, cancelled: &impl Fn() -> bool) -> bool {
189 let candidates = std::mem::take(&mut self.clean_tracked);
193 for path in candidates {
194 if cancelled() {
195 self.statuses = HashMap::new();
196 self.ignored_directories = HashSet::new();
197 self.clean_tracked = HashSet::new();
198 return false;
199 }
200 if path
201 .ancestors()
202 .skip(1)
203 .any(|ancestor| self.ignored_directories.contains(ancestor))
204 {
205 self.clean_tracked.insert(path);
206 }
207 }
208 if cancelled() {
210 self.statuses = HashMap::new();
211 self.ignored_directories = HashSet::new();
212 self.clean_tracked = HashSet::new();
213 return false;
214 }
215 true
216 }
217
218 #[cfg(not(feature = "git"))]
220 pub fn initialize(&mut self, _start_path: &Path) -> bool {
221 false
222 }
223
224 #[cfg(not(feature = "git"))]
225 fn initialize_cancellable(&mut self, _start_path: &Path, _token: &CancellationToken) -> bool {
226 false
227 }
228
229 pub fn get_status(&self, path: &Path) -> Option<GitStatus> {
236 self.get_status_canonical(path).or_else(|| {
237 dunce::canonicalize(path)
238 .ok()
239 .and_then(|normalized| self.get_status_canonical(&normalized))
240 })
241 }
242
243 #[cfg(any(all(feature = "git", not(unix)), test))]
246 fn utf8_status_path(&mut self, workdir: &Path, bytes: &[u8]) -> Option<PathBuf> {
247 match std::str::from_utf8(bytes) {
248 Ok(path) => Some(workdir.join(path)),
249 Err(error) => {
250 self.statuses = HashMap::new();
251 self.ignored_directories = HashSet::new();
252 self.clean_tracked = HashSet::new();
253 self.initialization_error = Some(format!("Cannot decode Git status path: {error}"));
254 None
255 }
256 }
257 }
258
259 pub fn get_status_canonical(&self, path: &Path) -> Option<GitStatus> {
266 if self
267 .scope_root
268 .as_ref()
269 .is_some_and(|scope| !path.starts_with(scope))
270 {
271 return None;
272 }
273 if let Some(status) = self.statuses.get(path) {
274 return Some(*status);
275 }
276 if self.clean_tracked.contains(path) {
277 return None;
278 }
279 path.ancestors()
280 .skip(1)
281 .any(|ancestor| self.ignored_directories.contains(ancestor))
282 .then_some(GitStatus::Ignored)
283 }
284
285 pub fn is_in_repo(&self, path: &Path) -> bool {
287 if let Some(ref root) = self.repo_root {
288 path.starts_with(root)
289 || dunce::canonicalize(path).is_ok_and(|normalized| normalized.starts_with(root))
290 } else {
291 false
292 }
293 }
294
295 pub fn repo_root(&self) -> Option<&Path> {
297 self.repo_root.as_deref()
298 }
299
300 pub fn initialization_error(&self) -> Option<&str> {
302 self.initialization_error.as_deref()
303 }
304
305 pub fn len(&self) -> usize {
308 self.statuses.len()
309 }
310
311 pub fn is_empty(&self) -> bool {
313 self.statuses.is_empty()
314 }
315}
316
317pub fn apply_git_status(tree: &mut gravityfile_core::FileTree) {
319 annotate_git_status(tree, &CancellationToken::new(), true);
320}
321
322#[cfg(feature = "git")]
324pub(crate) fn apply_git_status_cancellable(
325 tree: &mut gravityfile_core::FileTree,
326 token: &CancellationToken,
327) {
328 annotate_git_status(tree, token, false);
331}
332
333fn annotate_git_status(
334 tree: &mut gravityfile_core::FileTree,
335 token: &CancellationToken,
336 clear_stale: bool,
337) {
338 let mut cache = GitStatusCache::new();
339 cache.initialize_cancellable(&tree.root_path, token);
340 if token.is_cancelled() {
341 return;
342 }
343 if let Some(error) = cache.initialization_error() {
344 let warning = gravityfile_core::ScanWarning::new(
345 tree.root_path.clone(),
346 gravityfile_core::WarningKind::ReadError,
347 format!("Git status unavailable: {error}"),
348 );
349 crate::scanner::append_scan_warning(tree, warning);
350 return; }
352 if cache.is_empty() && !clear_stale {
353 return;
354 }
355
356 let root = dunce::canonicalize(&tree.root_path).unwrap_or_else(|_| tree.root_path.clone());
359
360 apply_status_iterative(&mut tree.root, &root, &cache, &|| token.is_cancelled());
361}
362
363fn apply_status_iterative(
365 node: &mut gravityfile_core::FileNode,
366 current_path: &Path,
367 cache: &GitStatusCache,
368 cancelled: &impl Fn() -> bool,
369) -> bool {
370 if cancelled() {
371 return false;
372 }
373 node.git_status = cache.get_status_canonical(current_path);
374 let mut path = current_path.to_path_buf();
375 let mut stack = vec![(node.children.iter_mut(), None::<PathBuf>)];
376 while let Some((children, _)) = stack.last_mut() {
377 let Some(child) = children.next() else {
378 let (_, previous) = stack.pop().expect("active annotation frame");
379 if stack.is_empty() {
380 break;
381 }
382 if let Some(previous) = previous {
383 path = previous;
384 } else {
385 path.pop();
386 }
387 continue;
388 };
389 if cancelled() {
390 return false;
391 }
392 let name = Path::new(child.file_name());
396 let mut components = name.components();
397 let single_component = matches!(components.next(), Some(std::path::Component::Normal(part)) if part == name.as_os_str())
398 && components.next().is_none();
399 let previous = (!single_component).then(|| path.clone());
400 path.push(name);
401 child.git_status = cache.get_status_canonical(&path);
402 stack.push((child.children.iter_mut(), previous));
403 }
404 true
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn annotation_handles_deep_trees_on_a_small_thread_stack() {
413 use gravityfile_core::{FileNode, Timestamps};
414 std::thread::Builder::new()
415 .stack_size(128 * 1024)
416 .spawn(|| {
417 let mut root =
418 FileNode::new_directory("d", Timestamps::with_modified(std::time::UNIX_EPOCH));
419 for _ in 1..4000 {
420 let mut parent = FileNode::new_directory(
421 "d",
422 Timestamps::with_modified(std::time::UNIX_EPOCH),
423 );
424 parent.children.push(root);
425 root = parent;
426 }
427 let mut path = PathBuf::from("root");
428 for _ in 1..4000 {
429 path.push("d");
430 }
431 let mut cache = GitStatusCache::new();
432 cache.statuses.insert(path, GitStatus::Modified);
433 assert!(apply_status_iterative(
434 &mut root,
435 Path::new("root"),
436 &cache,
437 &|| false
438 ));
439 let mut leaf = &root;
440 while let Some(child) = leaf.children.first() {
441 leaf = child;
442 }
443 assert_eq!(leaf.git_status, Some(GitStatus::Modified));
444 })
445 .unwrap()
446 .join()
447 .unwrap();
448 }
449
450 #[test]
451 fn annotation_restores_parent_path_after_non_component_names() {
452 use gravityfile_core::{FileNode, Timestamps};
453 let mut root =
454 FileNode::new_directory("root", Timestamps::with_modified(std::time::UNIX_EPOCH));
455 let mut cache = GitStatusCache::new();
456 for name in ["nested/name", "", "../outside", "sibling"] {
457 root.children.push(FileNode::new_directory(
458 name,
459 Timestamps::with_modified(std::time::UNIX_EPOCH),
460 ));
461 cache
462 .statuses
463 .insert(Path::new("root").join(name), GitStatus::Modified);
464 }
465 assert!(apply_status_iterative(
466 &mut root,
467 Path::new("root"),
468 &cache,
469 &|| false
470 ));
471 assert!(
472 root.children
473 .iter()
474 .all(|node| node.git_status == Some(GitStatus::Modified))
475 );
476 }
477
478 #[test]
479 fn clean_tracking_compaction_cancellation_discards_all_partial_annotations() {
480 for cancel_at in [1, 6, 101] {
481 let mut cache = GitStatusCache::new();
482 cache.ignored_directories.insert(PathBuf::from("generated"));
483 cache
484 .statuses
485 .insert(PathBuf::from("modified"), GitStatus::Modified);
486 for index in 0..100 {
487 cache
488 .clean_tracked
489 .insert(PathBuf::from(format!("generated/{index}")));
490 }
491 let checks = std::cell::Cell::new(0);
492 assert!(!cache.compact_clean_tracked(&|| {
493 checks.set(checks.get() + 1);
494 checks.get() == cancel_at
495 }));
496 assert_eq!(checks.get(), cancel_at);
497 assert_eq!(cache.statuses.capacity(), 0);
498 assert_eq!(cache.clean_tracked.capacity(), 0);
499 assert_eq!(cache.ignored_directories.capacity(), 0);
500 assert_eq!(
501 cache.get_status_canonical(Path::new("generated/untracked")),
502 None
503 );
504 }
505 }
506
507 #[test]
508 fn clean_tracking_compaction_retains_only_component_scoped_exceptions() {
509 let mut cache = GitStatusCache::new();
510 cache.ignored_directories.insert(PathBuf::from("generated"));
511 cache.clean_tracked.extend([
512 PathBuf::from("generated/tracked"),
513 PathBuf::from("generated-other/tracked"),
514 PathBuf::from("ordinary/tracked"),
515 ]);
516 assert!(cache.compact_clean_tracked(&|| false));
517 assert_eq!(
518 cache.clean_tracked,
519 HashSet::from([PathBuf::from("generated/tracked")])
520 );
521 assert_eq!(
522 cache.get_status_canonical(Path::new("generated/tracked")),
523 None
524 );
525 assert_eq!(
526 cache.get_status_canonical(Path::new("generated/untracked")),
527 Some(GitStatus::Ignored)
528 );
529 cache.clean_tracked.clear();
530 assert!(!cache.compact_clean_tracked(&|| true));
531 assert!(cache.ignored_directories.is_empty());
532 }
533
534 #[test]
535 fn undecodable_status_path_discards_partial_cache_and_reports_failure() {
536 let root = Path::new("repository");
537 let mut cache = GitStatusCache::new();
538 let path = cache
539 .utf8_status_path(root, "nested/café".as_bytes())
540 .unwrap();
541 assert_eq!(path, root.join("nested/café"));
542 assert!(cache.initialization_error().is_none());
543 cache.statuses.insert(path, GitStatus::Modified);
544 assert!(cache.utf8_status_path(root, b"invalid-\xff-name").is_none());
545 assert!(cache.is_empty());
546 assert_eq!(cache.statuses.capacity(), 0);
547 let error = cache.initialization_error().unwrap();
548 assert!(error.contains("Cannot decode Git status path"));
549 assert!(!error.contains("invalid-"));
550 }
551
552 #[test]
553 fn annotation_cancellation_stops_before_remaining_nodes() {
554 use gravityfile_core::{FileNode, Timestamps};
555 let mut root =
556 FileNode::new_directory("root", Timestamps::with_modified(std::time::UNIX_EPOCH));
557 for index in 1..1001 {
558 root.children.push(FileNode::new_directory(
559 index.to_string(),
560 Timestamps::with_modified(std::time::UNIX_EPOCH),
561 ));
562 }
563 let mut cache = GitStatusCache::new();
564 for index in 1..1001 {
565 cache.statuses.insert(
566 PathBuf::from("root").join(index.to_string()),
567 GitStatus::Modified,
568 );
569 }
570 let checks = std::cell::Cell::new(0);
571 let complete = apply_status_iterative(&mut root, Path::new("root"), &cache, &|| {
572 checks.set(checks.get() + 1);
573 checks.get() > 5
574 });
575 assert!(!complete);
576 assert_eq!(checks.get(), 6);
577 assert_eq!(
578 root.children
579 .iter()
580 .filter(|child| child.git_status.is_some())
581 .count(),
582 4
583 );
584 assert!(apply_status_iterative(
585 &mut root,
586 Path::new("root"),
587 &cache,
588 &|| false
589 ));
590 assert!(
591 root.children
592 .iter()
593 .all(|child| child.git_status == Some(GitStatus::Modified))
594 );
595 }
596
597 #[cfg(feature = "git")]
598 #[test]
599 fn cancelled_initialization_clears_reused_statuses_before_discovery() {
600 let mut cache = GitStatusCache::new();
601 cache.statuses.reserve(100_000);
602 cache
603 .statuses
604 .insert(PathBuf::from("old"), GitStatus::Modified);
605 cache.repo_root = Some(PathBuf::from("old"));
606 let token = CancellationToken::new();
607 token.cancel();
608 assert!(!cache.initialize_cancellable(Path::new("unused"), &token));
609 assert!(cache.is_empty());
610 assert_eq!(cache.statuses.capacity(), 0);
611 assert!(cache.repo_root().is_none());
612 }
613
614 #[test]
615 fn test_empty_cache() {
616 let cache = GitStatusCache::new();
617 assert!(cache.is_empty());
618 assert_eq!(cache.len(), 0);
619 assert!(cache.repo_root().is_none());
620 }
621
622 #[test]
623 fn test_get_status_nonexistent() {
624 let cache = GitStatusCache::new();
625 assert!(cache.get_status(Path::new("/nonexistent")).is_none());
626 }
627}