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;
9use std::path::{Path, PathBuf};
10
11use gravityfile_core::GitStatus;
12
13/// Git status cache for efficient lookups.
14#[derive(Debug, Default)]
15pub struct GitStatusCache {
16    /// Map of absolute file paths to their git status.
17    statuses: HashMap<PathBuf, GitStatus>,
18    /// Root path of the git repository (if found).
19    repo_root: Option<PathBuf>,
20}
21
22impl GitStatusCache {
23    /// Create a new empty cache.
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Initialize the cache by discovering and scanning the git repository.
29    ///
30    /// `start_path` is the directory we are scanning (the subtree root). The
31    /// git status query is restricted to that subtree via a pathspec so that
32    /// only relevant entries are loaded, keeping memory usage proportional to
33    /// the scanned directory rather than the whole repository.
34    ///
35    /// Returns true if a git repository was found and scanned.
36    #[cfg(feature = "git")]
37    pub fn initialize(&mut self, start_path: &Path) -> bool {
38        use git2::{Repository, StatusOptions};
39
40        // Try to find a git repository starting from the given path
41        let repo = match Repository::discover(start_path) {
42            Ok(repo) => repo,
43            Err(_) => return false,
44        };
45
46        // Get the workdir (root of the working tree)
47        let workdir = match repo.workdir() {
48            Some(dir) => dir.to_path_buf(),
49            None => return false, // Bare repository
50        };
51
52        self.repo_root = Some(workdir.clone());
53
54        // Build a pathspec that restricts status to the scanned subtree.
55        // We compute start_path relative to the repo workdir so git2 can
56        // apply it as a standard pathspec pattern.
57        let pathspec_str = start_path
58            .strip_prefix(&workdir)
59            .ok()
60            .and_then(|rel| rel.to_str())
61            .map(|s| {
62                if s.is_empty() {
63                    // Scanning the repo root — match everything.
64                    "**".to_string()
65                } else {
66                    format!("{s}/**")
67                }
68            })
69            .unwrap_or_else(|| "**".to_string());
70
71        // Get file statuses restricted to the subtree.
72        let mut opts = StatusOptions::new();
73        opts.include_untracked(true)
74            .include_ignored(true)
75            .recurse_untracked_dirs(true)
76            .include_unmodified(false)
77            .pathspec(&pathspec_str);
78
79        let statuses = match repo.statuses(Some(&mut opts)) {
80            Ok(s) => s,
81            Err(_) => return true, // Repository found but status failed
82        };
83
84        // Build the status map
85        for entry in statuses.iter() {
86            let status = entry.status();
87            let path = match entry.path() {
88                Some(p) => workdir.join(p),
89                None => continue,
90            };
91
92            let git_status = if status.is_conflicted() {
93                GitStatus::Conflict
94            } else if status.is_index_new()
95                || status.is_index_modified()
96                || status.is_index_deleted()
97                || status.is_index_renamed()
98                || status.is_index_typechange()
99            {
100                GitStatus::Staged
101            } else if status.is_wt_new() {
102                GitStatus::Untracked
103            } else if status.is_wt_modified()
104                || status.is_wt_deleted()
105                || status.is_wt_renamed()
106                || status.is_wt_typechange()
107            {
108                GitStatus::Modified
109            } else if status.is_ignored() {
110                GitStatus::Ignored
111            } else {
112                continue; // Skip clean files
113            };
114
115            self.statuses.insert(path, git_status);
116        }
117
118        true
119    }
120
121    /// Initialize without git feature (no-op).
122    #[cfg(not(feature = "git"))]
123    pub fn initialize(&mut self, _start_path: &Path) -> bool {
124        false
125    }
126
127    /// Get the git status for a path.
128    pub fn get_status(&self, path: &Path) -> Option<GitStatus> {
129        self.statuses.get(path).copied()
130    }
131
132    /// Check if the path is within a git repository.
133    pub fn is_in_repo(&self, path: &Path) -> bool {
134        if let Some(ref root) = self.repo_root {
135            path.starts_with(root)
136        } else {
137            false
138        }
139    }
140
141    /// Get the repository root path.
142    pub fn repo_root(&self) -> Option<&Path> {
143        self.repo_root.as_deref()
144    }
145
146    /// Get the number of cached statuses.
147    pub fn len(&self) -> usize {
148        self.statuses.len()
149    }
150
151    /// Check if the cache is empty.
152    pub fn is_empty(&self) -> bool {
153        self.statuses.is_empty()
154    }
155}
156
157/// Apply git statuses to a file tree in-place.
158pub fn apply_git_status(tree: &mut gravityfile_core::FileTree) {
159    let mut cache = GitStatusCache::new();
160    if !cache.initialize(&tree.root_path) {
161        return; // Not a git repository
162    }
163
164    apply_status_recursive(&mut tree.root, &tree.root_path, &cache);
165}
166
167/// Recursively apply git status to a node and its children.
168fn apply_status_recursive(
169    node: &mut gravityfile_core::FileNode,
170    current_path: &Path,
171    cache: &GitStatusCache,
172) {
173    // Apply status to this node
174    node.git_status = cache.get_status(current_path);
175
176    // Recursively apply to children
177    for child in &mut node.children {
178        let child_path = current_path.join(&*child.name);
179        apply_status_recursive(child, &child_path, cache);
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_empty_cache() {
189        let cache = GitStatusCache::new();
190        assert!(cache.is_empty());
191        assert_eq!(cache.len(), 0);
192        assert!(cache.repo_root().is_none());
193    }
194
195    #[test]
196    fn test_get_status_nonexistent() {
197        let cache = GitStatusCache::new();
198        assert!(cache.get_status(Path::new("/nonexistent")).is_none());
199    }
200}