Skip to main content

gravityfile_scan/
git.rs

1//! Git repository status detection.
2//!
3//! This module provides functionality to detect git status for files
4//! within a repository. It caches repository state for efficient lookups,
5//! and restricts the status query to the scanned subtree so that large
6//! monorepos do not force a full-repo status load.
7
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10
11use gravityfile_core::GitStatus;
12use tokio_util::sync::CancellationToken;
13
14/// Git status cache for efficient lookups.
15#[derive(Debug, Default)]
16pub struct GitStatusCache {
17    /// Map of absolute file paths to their git status.
18    statuses: HashMap<PathBuf, GitStatus>,
19    /// Ignored directories represent all untracked descendants without enumeration.
20    ignored_directories: HashSet<PathBuf>,
21    /// Clean indexed files beneath ignored directories must not inherit Ignored.
22    clean_tracked: HashSet<PathBuf>,
23    scope_root: Option<PathBuf>,
24    /// Root path of the git repository (if found).
25    repo_root: Option<PathBuf>,
26    /// Most recent initialization failure, distinct from no repository/clean state.
27    initialization_error: Option<String>,
28}
29
30impl GitStatusCache {
31    /// Create a new empty cache.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Initialize the cache by discovering and scanning the git repository.
37    ///
38    /// `start_path` is the directory we are scanning (the subtree root). The
39    /// git status query is restricted to that subtree via a pathspec so that
40    /// only relevant entries are loaded, keeping memory usage proportional to
41    /// the scanned directory rather than the whole repository.
42    ///
43    /// Returns true if a worktree was found, even if querying its status failed.
44    /// Inspect [`initialization_error`](Self::initialization_error) for failures.
45    #[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        // A cache may be reused for a different directory. Never leak stale
55        // statuses or an old repository root into the next initialization.
56        // Drop the table too: clear() keeps the previous query's allocation
57        // even when the next subtree is tiny or initialization is cancelled.
58        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        // Try to find a git repository starting from the given path
69        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        // The working tree's root, in the same canonical form as the start
80        // path and every lookup: libgit2 keeps the form it was given, which on
81        // Windows may be a short (8.3) name or use forward slashes.
82        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, // Bare repository
89        };
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        // Treat subtree names literally (including brackets and wildcard bytes).
108        // An empty pathspec list selects the repository root; failed path
109        // normalization must never silently widen a subtree query to the repo.
110        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            // Clean tracked files must override inherited ignored-directory
116            // status. This visits index entries, not every ignored build file.
117            .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        // Build the status map
135        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, // Worktree found, but enrichment is incomplete.
152            };
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; // Skip clean files
176            };
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        // Move retained paths into a fresh set so discarded index entries do
190        // not leave their capacity behind. Check between paths rather than
191        // using retain/shrink_to_fit, which cannot stop midway through the set.
192        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        // Includes empty caches and cancellation while processing the last path.
209        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    /// Initialize without git feature (no-op).
219    #[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    /// Get the git status for a path.
230    ///
231    /// Falls back to canonicalizing the path so callers can pass a
232    /// non-canonical form (a `/var` path on macOS, say, where the repository
233    /// workdir resolves to `/private/var`). That fallback costs a syscall, so
234    /// bulk callers should use [`get_status_canonical`](Self::get_status_canonical).
235    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    /// Non-Unix paths must decode before they can be represented by PathBuf.
244    /// Keep this boundary testable on Unix without pretending to run Windows.
245    #[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    /// Look up a path that is already known to be canonical.
260    ///
261    /// Unlike [`get_status`](Self::get_status) this never touches the
262    /// filesystem, which matters when annotating a whole tree: clean files miss
263    /// the map, and the canonicalizing fallback would then cost one syscall per
264    /// node across the entire scan.
265    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    /// Check if the path is within a git repository.
286    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    /// Get the repository root path.
296    pub fn repo_root(&self) -> Option<&Path> {
297        self.repo_root.as_deref()
298    }
299
300    /// Failure from the last initialization; absence of a repository is not an error.
301    pub fn initialization_error(&self) -> Option<&str> {
302        self.initialization_error.as_deref()
303    }
304
305    /// Get the number of explicit cached statuses. An ignored directory may
306    /// represent many descendants; clean tracked exceptions are not counted.
307    pub fn len(&self) -> usize {
308        self.statuses.len()
309    }
310
311    /// Check if the cache is empty.
312    pub fn is_empty(&self) -> bool {
313        self.statuses.is_empty()
314    }
315}
316
317/// Refresh git statuses in-place, clearing stale annotations on reused trees.
318pub fn apply_git_status(tree: &mut gravityfile_core::FileTree) {
319    annotate_git_status(tree, &CancellationToken::new(), true);
320}
321
322/// Scan-owned annotation may stop partway; the scanner discards the tree on cancellation.
323#[cfg(feature = "git")]
324pub(crate) fn apply_git_status_cancellable(
325    tree: &mut gravityfile_core::FileTree,
326    token: &CancellationToken,
327) {
328    // Scanner-owned nodes start without annotations. A clean repository needs
329    // no extra full-tree walk in this path.
330    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; // Do not clear old annotations as if a failed refresh were clean.
351    }
352    if cache.is_empty() && !clear_stale {
353        return;
354    }
355
356    // Resolve the root once. Every child path is built by joining onto it, so
357    // the whole walk can then use the syscall-free canonical lookup.
358    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
363/// Apply statuses without consuming call stack proportional to tree depth.
364fn 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        // Scanned names are single components, so one shared path buffer is
393        // sufficient. Publicly constructed trees can contain other names;
394        // retain their original Path::join behavior with a saved parent path.
395        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}