Skip to main content

fileview/git/
status.rs

1//! Git status detection and caching
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::sync::OnceLock;
7
8/// Cached git executable path
9static GIT_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
10
11/// Find git executable path using standard locations or which command
12fn find_git_executable() -> Option<&'static PathBuf> {
13    GIT_PATH
14        .get_or_init(|| {
15            // Priority: standard paths → which command fallback
16            let candidates = [
17                "/usr/bin/git",
18                "/usr/local/bin/git",
19                "/opt/homebrew/bin/git",
20            ];
21
22            for path in candidates {
23                let p = PathBuf::from(path);
24                if p.exists() {
25                    return Some(p);
26                }
27            }
28
29            // Fallback: which git
30            std::process::Command::new("which")
31                .arg("git")
32                .output()
33                .ok()
34                .filter(|o| o.status.success())
35                .and_then(|o| String::from_utf8(o.stdout).ok())
36                .map(|s| PathBuf::from(s.trim()))
37                .filter(|p| p.exists())
38        })
39        .as_ref()
40}
41
42/// Create a git Command using the validated executable path
43fn git_command() -> Option<Command> {
44    find_git_executable().map(Command::new)
45}
46
47/// Git file status
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum FileStatus {
50    /// File has been modified
51    Modified,
52    /// File has been staged for addition
53    Added,
54    /// File is not tracked by git
55    Untracked,
56    /// File has been deleted
57    Deleted,
58    /// File has been renamed
59    Renamed,
60    /// File is ignored by .gitignore
61    Ignored,
62    /// File has merge conflicts
63    Conflict,
64    /// File is clean (no changes)
65    #[default]
66    Clean,
67}
68
69/// Git repository status information
70#[derive(Debug)]
71pub struct GitStatus {
72    /// Root directory of the git repository
73    repo_root: PathBuf,
74    /// Cached file statuses
75    statuses: HashMap<PathBuf, FileStatus>,
76    /// Directory statuses (propagated from children)
77    dir_statuses: HashMap<PathBuf, FileStatus>,
78    /// Current branch name
79    branch: Option<String>,
80    /// Files that are staged (have changes in the index)
81    staged_files: std::collections::HashSet<PathBuf>,
82}
83
84impl GitStatus {
85    /// Detect git repository and load status
86    pub fn detect(path: &Path) -> Option<Self> {
87        let repo_root = find_git_root(path)?;
88        let branch = get_current_branch(&repo_root);
89        let (statuses, dir_statuses, staged_files) = load_git_status(&repo_root);
90
91        Some(Self {
92            repo_root,
93            statuses,
94            dir_statuses,
95            branch,
96            staged_files,
97        })
98    }
99
100    /// Get the status of a specific file or directory
101    pub fn get_status(&self, path: &Path) -> FileStatus {
102        // First check file statuses
103        if let Some(status) = self.statuses.get(path) {
104            return *status;
105        }
106
107        // Then check directory statuses
108        if let Some(status) = self.dir_statuses.get(path) {
109            return *status;
110        }
111
112        // Check if path is relative to repo root
113        if let Ok(relative) = path.strip_prefix(&self.repo_root) {
114            if let Some(status) = self.statuses.get(relative) {
115                return *status;
116            }
117            if let Some(status) = self.dir_statuses.get(relative) {
118                return *status;
119            }
120        }
121
122        FileStatus::Clean
123    }
124
125    /// Get the current branch name
126    pub fn branch(&self) -> Option<&str> {
127        self.branch.as_deref()
128    }
129
130    /// Get the repository root path
131    pub fn repo_root(&self) -> &Path {
132        &self.repo_root
133    }
134
135    /// Refresh git status (call after file operations)
136    pub fn refresh(&mut self) {
137        self.branch = get_current_branch(&self.repo_root);
138        let (statuses, dir_statuses, staged_files) = load_git_status(&self.repo_root);
139        self.statuses = statuses;
140        self.dir_statuses = dir_statuses;
141        self.staged_files = staged_files;
142    }
143
144    /// Check if a file is staged (has changes in the index)
145    pub fn is_staged(&self, path: &Path) -> bool {
146        // Check if the file is in the staged files set
147        if self.staged_files.contains(path) {
148            return true;
149        }
150
151        // Also check relative path
152        if let Ok(relative) = path.strip_prefix(&self.repo_root) {
153            if self.staged_files.contains(relative) {
154                return true;
155            }
156        }
157
158        false
159    }
160
161    /// Create a GitStatus with a specific repo root (for testing)
162    #[cfg(test)]
163    pub fn default_with_root(repo_root: PathBuf) -> Self {
164        Self {
165            repo_root,
166            statuses: std::collections::HashMap::new(),
167            dir_statuses: std::collections::HashMap::new(),
168            branch: None,
169            staged_files: std::collections::HashSet::new(),
170        }
171    }
172}
173
174/// Find the root of the git repository containing the given path
175fn find_git_root(path: &Path) -> Option<PathBuf> {
176    let output = git_command()?
177        .args(["rev-parse", "--show-toplevel"])
178        .current_dir(path)
179        .output()
180        .ok()?;
181
182    if output.status.success() {
183        let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
184        Some(PathBuf::from(root))
185    } else {
186        None
187    }
188}
189
190/// Get the current branch name
191fn get_current_branch(repo_root: &Path) -> Option<String> {
192    let output = git_command()?
193        .args(["rev-parse", "--abbrev-ref", "HEAD"])
194        .current_dir(repo_root)
195        .output()
196        .ok()?;
197
198    if output.status.success() {
199        let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
200        if branch == "HEAD" {
201            // Detached HEAD state - try to get commit hash
202            let hash_output = git_command()?
203                .args(["rev-parse", "--short", "HEAD"])
204                .current_dir(repo_root)
205                .output()
206                .ok()?;
207            if hash_output.status.success() {
208                return Some(format!(
209                    "detached@{}",
210                    String::from_utf8_lossy(&hash_output.stdout).trim()
211                ));
212            }
213        }
214        Some(branch)
215    } else {
216        None
217    }
218}
219
220/// Load git status for all files in the repository
221fn load_git_status(
222    repo_root: &Path,
223) -> (
224    HashMap<PathBuf, FileStatus>,
225    HashMap<PathBuf, FileStatus>,
226    std::collections::HashSet<PathBuf>,
227) {
228    use std::collections::HashSet;
229
230    let mut statuses = HashMap::new();
231    let mut dir_statuses: HashMap<PathBuf, FileStatus> = HashMap::new();
232    let mut staged_files: HashSet<PathBuf> = HashSet::new();
233
234    // Get status with porcelain format for machine parsing
235    // -uall shows all untracked files (required for per-file status display)
236    let Some(mut cmd) = git_command() else {
237        return (statuses, dir_statuses, staged_files);
238    };
239    let output = cmd
240        .args(["status", "--porcelain=v1", "-uall", "--ignored"])
241        .current_dir(repo_root)
242        .output();
243
244    let output = match output {
245        Ok(o) if o.status.success() => o,
246        _ => return (statuses, dir_statuses, staged_files),
247    };
248
249    let stdout = String::from_utf8_lossy(&output.stdout);
250
251    for line in stdout.lines() {
252        if line.len() < 4 {
253            continue;
254        }
255
256        let index_status = line.chars().next().unwrap_or(' ');
257        let worktree_status = line.chars().nth(1).unwrap_or(' ');
258        let path_str = &line[3..];
259
260        // Handle renamed files (format: "R  old -> new")
261        let file_path = if path_str.contains(" -> ") {
262            path_str.split(" -> ").last().unwrap_or(path_str)
263        } else {
264            path_str
265        };
266
267        let path = PathBuf::from(file_path);
268        let status = parse_status(index_status, worktree_status);
269
270        // Track staged files (index has changes: M, A, D, R, C)
271        if matches!(index_status, 'M' | 'A' | 'D' | 'R' | 'C') {
272            staged_files.insert(path.clone());
273        }
274
275        if status != FileStatus::Clean {
276            statuses.insert(path.clone(), status);
277
278            // Propagate status to parent directories
279            let mut parent = path.parent();
280            while let Some(dir) = parent {
281                if dir.as_os_str().is_empty() {
282                    break;
283                }
284                let current = dir_statuses
285                    .entry(dir.to_path_buf())
286                    .or_insert(FileStatus::Clean);
287                *current = merge_status(*current, status);
288                parent = dir.parent();
289            }
290        }
291    }
292
293    (statuses, dir_statuses, staged_files)
294}
295
296/// Parse git status characters into FileStatus
297fn parse_status(index: char, worktree: char) -> FileStatus {
298    // Check for conflicts first
299    if index == 'U'
300        || worktree == 'U'
301        || (index == 'A' && worktree == 'A')
302        || (index == 'D' && worktree == 'D')
303    {
304        return FileStatus::Conflict;
305    }
306
307    // Check for ignored
308    if index == '!' {
309        return FileStatus::Ignored;
310    }
311
312    // Check for untracked
313    if index == '?' {
314        return FileStatus::Untracked;
315    }
316
317    // Check for renamed
318    if index == 'R' || worktree == 'R' {
319        return FileStatus::Renamed;
320    }
321
322    // Check for added
323    if index == 'A' {
324        return FileStatus::Added;
325    }
326
327    // Check for deleted
328    if index == 'D' || worktree == 'D' {
329        return FileStatus::Deleted;
330    }
331
332    // Check for modified
333    if index == 'M' || worktree == 'M' {
334        return FileStatus::Modified;
335    }
336
337    FileStatus::Clean
338}
339
340/// Merge two statuses, preferring the more "severe" one
341fn merge_status(a: FileStatus, b: FileStatus) -> FileStatus {
342    use FileStatus::*;
343
344    match (a, b) {
345        // Conflict is highest priority
346        (Conflict, _) | (_, Conflict) => Conflict,
347        // Then Deleted
348        (Deleted, _) | (_, Deleted) => Deleted,
349        // Then Modified
350        (Modified, _) | (_, Modified) => Modified,
351        // Then Renamed
352        (Renamed, _) | (_, Renamed) => Renamed,
353        // Then Added
354        (Added, _) | (_, Added) => Added,
355        // Then Untracked
356        (Untracked, _) | (_, Untracked) => Untracked,
357        // Ignored doesn't propagate
358        (Ignored, other) | (other, Ignored) => other,
359        // Default to Clean
360        (Clean, Clean) => Clean,
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_parse_status_modified() {
370        assert_eq!(parse_status('M', ' '), FileStatus::Modified);
371        assert_eq!(parse_status(' ', 'M'), FileStatus::Modified);
372        assert_eq!(parse_status('M', 'M'), FileStatus::Modified);
373    }
374
375    #[test]
376    fn test_parse_status_added() {
377        assert_eq!(parse_status('A', ' '), FileStatus::Added);
378    }
379
380    #[test]
381    fn test_parse_status_deleted() {
382        assert_eq!(parse_status('D', ' '), FileStatus::Deleted);
383        assert_eq!(parse_status(' ', 'D'), FileStatus::Deleted);
384    }
385
386    #[test]
387    fn test_parse_status_untracked() {
388        assert_eq!(parse_status('?', '?'), FileStatus::Untracked);
389    }
390
391    #[test]
392    fn test_parse_status_ignored() {
393        assert_eq!(parse_status('!', '!'), FileStatus::Ignored);
394    }
395
396    #[test]
397    fn test_parse_status_conflict() {
398        assert_eq!(parse_status('U', 'U'), FileStatus::Conflict);
399        assert_eq!(parse_status('A', 'A'), FileStatus::Conflict);
400    }
401
402    #[test]
403    fn test_parse_status_renamed() {
404        assert_eq!(parse_status('R', ' '), FileStatus::Renamed);
405    }
406
407    #[test]
408    fn test_merge_status() {
409        assert_eq!(
410            merge_status(FileStatus::Clean, FileStatus::Modified),
411            FileStatus::Modified
412        );
413        assert_eq!(
414            merge_status(FileStatus::Modified, FileStatus::Conflict),
415            FileStatus::Conflict
416        );
417        assert_eq!(
418            merge_status(FileStatus::Untracked, FileStatus::Added),
419            FileStatus::Added
420        );
421    }
422}