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 MVP (no libgit2).
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use f00_core::{Entry, GitStatus, Listing};
10
11/// Snapshot of git status for paths under a repository root.
12#[derive(Debug, Default, Clone)]
13pub struct GitIndex {
14    /// Absolute or relative paths → status. Keys are normalized relative to repo root.
15    statuses: HashMap<PathBuf, GitStatus>,
16    repo_root: Option<PathBuf>,
17}
18
19impl GitIndex {
20    pub fn empty() -> Self {
21        Self::default()
22    }
23
24    pub fn repo_root(&self) -> Option<&Path> {
25        self.repo_root.as_deref()
26    }
27
28    pub fn status_for(&self, path: &Path) -> GitStatus {
29        // Try exact, then file name relative to repo.
30        if let Some(st) = self.statuses.get(path) {
31            return *st;
32        }
33        if let Some(root) = &self.repo_root {
34            if let Ok(rel) = path.strip_prefix(root) {
35                if let Some(st) = self.statuses.get(rel) {
36                    return *st;
37                }
38            }
39        }
40        // Basename fallback
41        if let Some(name) = path.file_name() {
42            for (k, v) in &self.statuses {
43                if k.file_name() == Some(name) && k.components().count() == 1 {
44                    return *v;
45                }
46            }
47        }
48        GitStatus::Clean
49    }
50
51    /// Discover repo from `start` and load porcelain status.
52    pub fn discover(start: &Path) -> Self {
53        let root = match find_repo_root(start) {
54            Some(r) => r,
55            None => return Self::empty(),
56        };
57
58        let output = Command::new("git")
59            .args(["status", "--porcelain", "-uall"])
60            .current_dir(&root)
61            .output();
62
63        let output = match output {
64            Ok(o) if o.status.success() => o,
65            _ => {
66                return Self {
67                    statuses: HashMap::new(),
68                    repo_root: Some(root),
69                };
70            }
71        };
72
73        let stdout = String::from_utf8_lossy(&output.stdout);
74        let mut statuses = HashMap::new();
75
76        for line in stdout.lines() {
77            if line.len() < 3 {
78                continue;
79            }
80            let code = &line[..2];
81            let rest = line[3..].trim();
82            // Handle renames: `R  old -> new`
83            let path_str = if let Some((left, _right)) = rest.split_once(" -> ") {
84                left.trim()
85            } else {
86                rest
87            };
88            // Strip optional quotes
89            let path_str = path_str.trim_matches('"');
90            let rel = PathBuf::from(path_str);
91            let st = parse_porcelain_code(code);
92            statuses.insert(rel.clone(), st);
93            statuses.insert(root.join(&rel), st);
94        }
95
96        Self {
97            statuses,
98            repo_root: Some(root),
99        }
100    }
101}
102
103/// Parse the two-character porcelain XY code into a single simplified status.
104fn parse_porcelain_code(code: &str) -> GitStatus {
105    let chars: Vec<char> = code.chars().collect();
106    let x = chars.first().copied().unwrap_or(' ');
107    let y = chars.get(1).copied().unwrap_or(' ');
108
109    // Prefer worktree (Y) then index (X).
110    for c in [y, x] {
111        match c {
112            'M' => return GitStatus::Modified,
113            'A' => return GitStatus::Added,
114            'D' => return GitStatus::Deleted,
115            'R' | 'C' => return GitStatus::Renamed,
116            'U' => return GitStatus::Conflicted,
117            '?' => return GitStatus::Untracked,
118            '!' => return GitStatus::Ignored,
119            _ => {}
120        }
121    }
122    GitStatus::Unknown
123}
124
125/// Walk up from `start` looking for a `.git` directory/file.
126pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
127    let start = if start.is_file() {
128        start.parent()?.to_path_buf()
129    } else {
130        start.to_path_buf()
131    };
132    let abs = std::fs::canonicalize(&start).unwrap_or(start);
133    let mut cur = abs.as_path();
134    loop {
135        let git = cur.join(".git");
136        if git.exists() {
137            return Some(cur.to_path_buf());
138        }
139        cur = cur.parent()?;
140    }
141}
142
143/// Annotate listing entries with git status in place.
144pub fn annotate_listing(listing: &mut Listing, index: &GitIndex) {
145    for entry in &mut listing.entries {
146        if entry.is_dir_header {
147            continue;
148        }
149        entry.git_status = index.status_for(&entry.path);
150    }
151}
152
153/// Annotate many listings; **one** porcelain map per git repo root (reused).
154pub fn annotate_listings(listings: &mut [Listing]) {
155    use std::collections::HashMap;
156    use std::path::PathBuf;
157
158    // Cache GitIndex by discovered repo root (or by listing root when not in a repo).
159    let mut by_repo: HashMap<PathBuf, GitIndex> = HashMap::new();
160    for listing in listings {
161        let key = find_repo_root(&listing.root).unwrap_or_else(|| listing.root.clone());
162        let index = by_repo
163            .entry(key)
164            .or_insert_with(|| GitIndex::discover(&listing.root));
165        annotate_listing(listing, index);
166    }
167}
168
169/// Convenience: annotate a free list of entries using a starting path for discovery.
170pub fn annotate_entries(entries: &mut [Entry], start: &Path) {
171    let index = GitIndex::discover(start);
172    for entry in entries {
173        if !entry.is_dir_header {
174            entry.git_status = index.status_for(&entry.path);
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn parse_modified() {
185        assert_eq!(parse_porcelain_code(" M"), GitStatus::Modified);
186        assert_eq!(parse_porcelain_code("M "), GitStatus::Modified);
187        assert_eq!(parse_porcelain_code("MM"), GitStatus::Modified);
188    }
189
190    #[test]
191    fn parse_untracked() {
192        assert_eq!(parse_porcelain_code("??"), GitStatus::Untracked);
193    }
194
195    #[test]
196    fn parse_added() {
197        assert_eq!(parse_porcelain_code("A "), GitStatus::Added);
198    }
199
200    #[test]
201    fn find_repo_from_workspace() {
202        // Prefer a disposable repo so CARGO_MANIFEST_DIR path moves (e.g. rename)
203        // never leave a baked-in path without a .git.
204        let base = std::env::temp_dir().join(format!(
205            "f00-git-find-{}-{}",
206            std::process::id(),
207            std::time::SystemTime::now()
208                .duration_since(std::time::UNIX_EPOCH)
209                .map(|d| d.as_nanos())
210                .unwrap_or(0)
211        ));
212        std::fs::create_dir_all(base.join("nested/deep")).unwrap();
213        std::fs::create_dir_all(base.join(".git")).unwrap();
214        let found = find_repo_root(&base.join("nested/deep")).expect("repo root");
215        // canonicalize both sides: macOS /var → /private/var, Windows \\?\ prefixes.
216        let found_c = std::fs::canonicalize(&found).unwrap_or(found);
217        let base_c = std::fs::canonicalize(&base).unwrap_or(base.clone());
218        assert_eq!(found_c, base_c);
219        let _ = std::fs::remove_dir_all(&base);
220
221        // Also accept the live workspace when present (non-fatal if path moved).
222        let _ = find_repo_root(Path::new(env!("CARGO_MANIFEST_DIR")));
223    }
224}