Skip to main content

f00_git/
lib.rs

1//! Optional git status integration for **f00**.
2//!
3//! Uses `git status --porcelain` as a lightweight subprocess (no libgit2).
4//! Results are cached per repo root and invalidated when `.git/index` mtimes.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::sync::Mutex;
10use std::time::SystemTime;
11
12use f00_core::{Entry, GitStatus, Listing};
13
14/// How aggressively to collect untracked files.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum UntrackedMode {
17    /// `git status -uall` — every untracked path (default for flat listings).
18    #[default]
19    All,
20    /// `git status -uno` — skip untracked (faster; good for `-R` / `--tree`).
21    No,
22}
23
24/// Snapshot of git status for paths under a repository root.
25#[derive(Debug, Default, Clone)]
26pub struct GitIndex {
27    /// Absolute or relative paths → status. Keys are normalized relative to repo root.
28    statuses: HashMap<PathBuf, GitStatus>,
29    repo_root: Option<PathBuf>,
30    /// Index mtime used for cache invalidation (if available).
31    #[allow(dead_code)]
32    index_mtime: Option<SystemTime>,
33    #[allow(dead_code)]
34    untracked: UntrackedMode,
35}
36
37#[derive(Clone)]
38struct CacheEntry {
39    index: GitIndex,
40    index_mtime: Option<SystemTime>,
41    untracked: UntrackedMode,
42}
43
44fn global_cache() -> &'static Mutex<HashMap<PathBuf, CacheEntry>> {
45    use std::sync::OnceLock;
46    static CACHE: OnceLock<Mutex<HashMap<PathBuf, CacheEntry>>> = OnceLock::new();
47    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
48}
49
50fn index_mtime(repo: &Path) -> Option<SystemTime> {
51    let index = repo.join(".git/index");
52    std::fs::metadata(index).and_then(|m| m.modified()).ok()
53}
54
55impl GitIndex {
56    pub fn empty() -> Self {
57        Self::default()
58    }
59
60    pub fn repo_root(&self) -> Option<&Path> {
61        self.repo_root.as_deref()
62    }
63
64    pub fn status_for(&self, path: &Path) -> GitStatus {
65        if let Some(st) = self.statuses.get(path) {
66            return *st;
67        }
68        if let Some(root) = &self.repo_root {
69            if let Ok(rel) = path.strip_prefix(root) {
70                if let Some(st) = self.statuses.get(rel) {
71                    return *st;
72                }
73            }
74        }
75        // Basename fallback (only single-component keys)
76        if let Some(name) = path.file_name() {
77            for (k, v) in &self.statuses {
78                if k.file_name() == Some(name) && k.components().count() == 1 {
79                    return *v;
80                }
81            }
82        }
83        GitStatus::Clean
84    }
85
86    /// Discover repo from `start` and load porcelain status (cached).
87    pub fn discover(start: &Path) -> Self {
88        Self::discover_with(start, UntrackedMode::All)
89    }
90
91    /// Discover with untracked policy (uses process-wide cache).
92    pub fn discover_with(start: &Path, untracked: UntrackedMode) -> Self {
93        let root = match find_repo_root(start) {
94            Some(r) => r,
95            None => return Self::empty(),
96        };
97
98        let mtime = index_mtime(&root);
99        if let Ok(cache) = global_cache().lock() {
100            if let Some(hit) = cache.get(&root) {
101                if hit.untracked == untracked && hit.index_mtime == mtime {
102                    return hit.index.clone();
103                }
104            }
105        }
106
107        let index = Self::load_fresh(&root, mtime, untracked);
108        if let Ok(mut cache) = global_cache().lock() {
109            cache.insert(
110                root.clone(),
111                CacheEntry {
112                    index: index.clone(),
113                    index_mtime: mtime,
114                    untracked,
115                },
116            );
117            // Soft cap so long-lived CLIs don't grow unbounded across many repos.
118            if cache.len() > 32 {
119                // Drop an arbitrary old entry (HashMap iter order is fine here).
120                if let Some(k) = cache.keys().next().cloned() {
121                    cache.remove(&k);
122                }
123            }
124        }
125        index
126    }
127
128    fn load_fresh(root: &Path, mtime: Option<SystemTime>, untracked: UntrackedMode) -> Self {
129        let uflag = match untracked {
130            UntrackedMode::All => "-uall",
131            UntrackedMode::No => "-uno",
132        };
133        let output = Command::new("git")
134            .args(["status", "--porcelain", "--ignored=no", uflag])
135            .current_dir(root)
136            .output();
137
138        let output = match output {
139            Ok(o) if o.status.success() => o,
140            _ => {
141                return Self {
142                    statuses: HashMap::new(),
143                    repo_root: Some(root.to_path_buf()),
144                    index_mtime: mtime,
145                    untracked,
146                };
147            }
148        };
149
150        let stdout = String::from_utf8_lossy(&output.stdout);
151        let mut statuses = HashMap::new();
152
153        for line in stdout.lines() {
154            if line.len() < 3 {
155                continue;
156            }
157            let code = &line[..2];
158            let rest = line[3..].trim();
159            let path_str = if let Some((left, _right)) = rest.split_once(" -> ") {
160                left.trim()
161            } else {
162                rest
163            };
164            let path_str = path_str.trim_matches('"');
165            let rel = PathBuf::from(path_str);
166            let st = parse_porcelain_code(code);
167            statuses.insert(rel.clone(), st);
168            statuses.insert(root.join(&rel), st);
169        }
170
171        Self {
172            statuses,
173            repo_root: Some(root.to_path_buf()),
174            index_mtime: mtime,
175            untracked,
176        }
177    }
178}
179
180/// Parse the two-character porcelain XY code into a single simplified status.
181fn parse_porcelain_code(code: &str) -> GitStatus {
182    let chars: Vec<char> = code.chars().collect();
183    let x = chars.first().copied().unwrap_or(' ');
184    let y = chars.get(1).copied().unwrap_or(' ');
185
186    for c in [y, x] {
187        match c {
188            'M' => return GitStatus::Modified,
189            'A' => return GitStatus::Added,
190            'D' => return GitStatus::Deleted,
191            'R' | 'C' => return GitStatus::Renamed,
192            'U' => return GitStatus::Conflicted,
193            '?' => return GitStatus::Untracked,
194            '!' => return GitStatus::Ignored,
195            _ => {}
196        }
197    }
198    GitStatus::Unknown
199}
200
201/// Walk up from `start` looking for a `.git` directory/file.
202pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
203    let start = if start.is_file() {
204        start.parent()?.to_path_buf()
205    } else {
206        start.to_path_buf()
207    };
208    let abs = std::fs::canonicalize(&start).unwrap_or(start);
209    let mut cur = abs.as_path();
210    loop {
211        let git = cur.join(".git");
212        if git.exists() {
213            return Some(cur.to_path_buf());
214        }
215        cur = cur.parent()?;
216    }
217}
218
219/// Annotate listing entries with git status in place.
220pub fn annotate_listing(listing: &mut Listing, index: &GitIndex) {
221    for entry in &mut listing.entries {
222        if entry.is_dir_header {
223            continue;
224        }
225        entry.git_status = index.status_for(&entry.path);
226    }
227}
228
229/// Annotate many listings; **one** porcelain map per git repo root (reused + process cache).
230///
231/// When any listing is recursive (depth>0 entries or dir headers beyond root), use
232/// [`UntrackedMode::No`] for that repo to avoid scanning every untracked path.
233pub fn annotate_listings(listings: &mut [Listing]) {
234    use std::collections::HashMap;
235
236    let mut by_repo: HashMap<PathBuf, GitIndex> = HashMap::new();
237    for listing in listings.iter_mut() {
238        let recursive = listing
239            .entries
240            .iter()
241            .any(|e| e.depth > 0 || e.is_dir_header);
242        let mode = if recursive {
243            UntrackedMode::No
244        } else {
245            UntrackedMode::All
246        };
247        let key = find_repo_root(&listing.root).unwrap_or_else(|| listing.root.clone());
248        let index = by_repo
249            .entry(key)
250            .or_insert_with(|| GitIndex::discover_with(&listing.root, mode));
251        annotate_listing(listing, index);
252    }
253}
254
255/// Convenience: annotate a free list of entries using a starting path for discovery.
256pub fn annotate_entries(entries: &mut [Entry], start: &Path) {
257    let recursive = entries.iter().any(|e| e.depth > 0);
258    let mode = if recursive {
259        UntrackedMode::No
260    } else {
261        UntrackedMode::All
262    };
263    let index = GitIndex::discover_with(start, mode);
264    for entry in entries {
265        if !entry.is_dir_header {
266            entry.git_status = index.status_for(&entry.path);
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn parse_modified() {
277        assert_eq!(parse_porcelain_code(" M"), GitStatus::Modified);
278        assert_eq!(parse_porcelain_code("M "), GitStatus::Modified);
279        assert_eq!(parse_porcelain_code("MM"), GitStatus::Modified);
280    }
281
282    #[test]
283    fn parse_untracked() {
284        assert_eq!(parse_porcelain_code("??"), GitStatus::Untracked);
285    }
286
287    #[test]
288    fn parse_added() {
289        assert_eq!(parse_porcelain_code("A "), GitStatus::Added);
290    }
291
292    #[test]
293    fn find_repo_from_workspace() {
294        let base = std::env::temp_dir().join(format!(
295            "f00-git-find-{}-{}",
296            std::process::id(),
297            std::time::SystemTime::now()
298                .duration_since(std::time::UNIX_EPOCH)
299                .map(|d| d.as_nanos())
300                .unwrap_or(0)
301        ));
302        std::fs::create_dir_all(base.join("nested/deep")).unwrap();
303        std::fs::create_dir_all(base.join(".git")).unwrap();
304        let found = find_repo_root(&base.join("nested/deep")).expect("repo root");
305        let found_c = std::fs::canonicalize(&found).unwrap_or(found);
306        let base_c = std::fs::canonicalize(&base).unwrap_or(base.clone());
307        assert_eq!(found_c, base_c);
308        let _ = std::fs::remove_dir_all(&base);
309        let _ = find_repo_root(Path::new(env!("CARGO_MANIFEST_DIR")));
310    }
311
312    #[test]
313    fn cache_returns_same_for_second_discover() {
314        let base = std::env::temp_dir().join(format!(
315            "f00-git-cache-{}-{}",
316            std::process::id(),
317            std::time::SystemTime::now()
318                .duration_since(std::time::UNIX_EPOCH)
319                .map(|d| d.as_nanos())
320                .unwrap_or(0)
321        ));
322        std::fs::create_dir_all(base.join(".git")).unwrap();
323        // No real git repo — discover returns empty statuses but still caches root.
324        let a = GitIndex::discover_with(&base, UntrackedMode::No);
325        let b = GitIndex::discover_with(&base, UntrackedMode::No);
326        assert_eq!(a.repo_root(), b.repo_root());
327        let _ = std::fs::remove_dir_all(&base);
328    }
329}