Skip to main content

tui_file_explorer/
git.rs

1//! Lightweight `git status` integration: a per-entry change marker.
2//!
3//! Shells out to the `git` binary (no libgit2 dependency). Everything here is
4//! best-effort: no repository, no `git` on `PATH`, or any failure simply
5//! yields no markers.
6
7use std::{
8    collections::HashMap,
9    path::{Path, PathBuf},
10    process::Command,
11};
12
13/// Change state of a path, ordered by display priority (highest last).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub enum GitMark {
16    /// Untracked file (`?`).
17    Untracked,
18    /// Added / staged new file (`A`).
19    Added,
20    /// Renamed or copied (`R`).
21    Renamed,
22    /// Deleted (`D`).
23    Deleted,
24    /// Modified in the index or work tree (`M`).
25    Modified,
26    /// Unmerged / conflicted (`U`).
27    Conflict,
28}
29
30impl GitMark {
31    /// Single-character marker.
32    #[must_use]
33    pub fn symbol(self) -> char {
34        match self {
35            Self::Untracked => '?',
36            Self::Added => 'A',
37            Self::Renamed => 'R',
38            Self::Deleted => 'D',
39            Self::Modified => 'M',
40            Self::Conflict => 'U',
41        }
42    }
43}
44
45/// Parsed result of one `git status`: a mark for every changed path **and**
46/// every ancestor directory below the repository root.
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct GitStatus {
49    marks: HashMap<PathBuf, GitMark>,
50}
51
52impl GitStatus {
53    /// The marker for `path` (a changed file, or a directory containing one).
54    #[must_use]
55    pub fn mark(&self, path: &Path) -> Option<GitMark> {
56        self.marks.get(path).copied()
57    }
58
59    /// `true` when nothing is modified.
60    #[must_use]
61    pub fn is_clean(&self) -> bool {
62        self.marks.is_empty()
63    }
64
65    /// Number of marked paths (files plus aggregated directories).
66    #[must_use]
67    pub fn len(&self) -> usize {
68        self.marks.len()
69    }
70
71    /// Alias of [`is_clean`](Self::is_clean).
72    #[must_use]
73    pub fn is_empty(&self) -> bool {
74        self.marks.is_empty()
75    }
76
77    fn insert(&mut self, root: &Path, rel: &str, mark: GitMark) {
78        let mut path = root.join(rel.trim_end_matches('/'));
79        // The file itself, then every ancestor up to (excluding) the root.
80        self.marks
81            .entry(path.clone())
82            .and_modify(|m| *m = (*m).max(mark))
83            .or_insert(mark);
84        while path.pop() && path != root && path.starts_with(root) {
85            self.marks
86                .entry(path.clone())
87                .and_modify(|m| *m = (*m).max(mark))
88                .or_insert(mark);
89        }
90    }
91}
92
93/// Map a porcelain-v1 `XY` status pair to a mark.
94fn classify(x: u8, y: u8) -> Option<GitMark> {
95    match (x, y) {
96        (b'?', b'?') => Some(GitMark::Untracked),
97        (b'!', b'!') => None,
98        (b'U', _) | (_, b'U') | (b'A', b'A') | (b'D', b'D') => Some(GitMark::Conflict),
99        (b'M', _) | (_, b'M') | (b'T', _) | (_, b'T') => Some(GitMark::Modified),
100        (b'D', _) | (_, b'D') => Some(GitMark::Deleted),
101        (b'R', _) | (_, b'R') | (b'C', _) | (_, b'C') => Some(GitMark::Renamed),
102        (b'A', _) | (_, b'A') => Some(GitMark::Added),
103        _ => None,
104    }
105}
106
107/// Parse `git status --porcelain=v1 -z` output for a repository at `root`.
108#[must_use]
109pub fn parse_porcelain_z(root: &Path, output: &[u8]) -> GitStatus {
110    let mut status = GitStatus::default();
111    let mut records = output.split(|&b| b == 0).filter(|r| !r.is_empty());
112    while let Some(rec) = records.next() {
113        if rec.len() < 4 {
114            continue;
115        }
116        let (x, y) = (rec[0], rec[1]);
117        let path = String::from_utf8_lossy(&rec[3..]).into_owned();
118        // Renames/copies are followed by a second NUL-separated "from" path.
119        if x == b'R' || x == b'C' || y == b'R' || y == b'C' {
120            let _ = records.next();
121        }
122        if let Some(mark) = classify(x, y) {
123            status.insert(root, &path, mark);
124        }
125    }
126    status
127}
128
129/// Run `git` in `dir` and return the repository status, or `None` when `dir`
130/// is not inside a work tree or `git` is unavailable.
131#[must_use]
132pub fn status_for(dir: &Path) -> Option<GitStatus> {
133    let top = Command::new("git")
134        .arg("-C")
135        .arg(dir)
136        .args(["rev-parse", "--show-toplevel"])
137        .output()
138        .ok()
139        .filter(|o| o.status.success())?;
140    let root = PathBuf::from(String::from_utf8_lossy(&top.stdout).trim_end());
141    // `rev-parse` may resolve symlinks (and on Windows prints `C:/…` long
142    // names); compare canonical forms, then keep the user's view of `dir`.
143    let canon_root = root.canonicalize().unwrap_or_else(|_| root.clone());
144    let root = match dir.canonicalize() {
145        Ok(canon_dir) if canon_dir.starts_with(&canon_root) => {
146            let rel_depth = canon_dir
147                .strip_prefix(&canon_root)
148                .map_or(0, |p| p.components().count());
149            let mut r = dir.to_path_buf();
150            for _ in 0..rel_depth {
151                r.pop();
152            }
153            r
154        }
155        _ => root,
156    };
157    let out = Command::new("git")
158        .arg("-C")
159        .arg(&root)
160        .args(["status", "--porcelain=v1", "-z", "--untracked-files=normal"])
161        .output()
162        .ok()
163        .filter(|o| o.status.success())?;
164    Some(parse_porcelain_z(&root, &out.stdout))
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn root() -> PathBuf {
172        PathBuf::from("/repo")
173    }
174
175    #[test]
176    fn parses_basic_statuses() {
177        let out = b" M src/lib.rs\0A  new.txt\0?? scratch.txt\0 D gone.txt\0";
178        let s = parse_porcelain_z(&root(), out);
179        assert_eq!(
180            s.mark(Path::new("/repo/src/lib.rs")),
181            Some(GitMark::Modified)
182        );
183        assert_eq!(s.mark(Path::new("/repo/new.txt")), Some(GitMark::Added));
184        assert_eq!(
185            s.mark(Path::new("/repo/scratch.txt")),
186            Some(GitMark::Untracked)
187        );
188        assert_eq!(s.mark(Path::new("/repo/gone.txt")), Some(GitMark::Deleted));
189    }
190
191    #[test]
192    fn directories_aggregate_highest_priority() {
193        let out = b"?? a/b/new.txt\0 M a/c.rs\0";
194        let s = parse_porcelain_z(&root(), out);
195        assert_eq!(s.mark(Path::new("/repo/a")), Some(GitMark::Modified));
196        assert_eq!(s.mark(Path::new("/repo/a/b")), Some(GitMark::Untracked));
197        assert_eq!(
198            s.mark(Path::new("/repo")),
199            None,
200            "root itself is not marked"
201        );
202    }
203
204    #[test]
205    fn rename_consumes_the_from_path() {
206        let out = b"R  new_name.rs\0old_name.rs\0 M other.rs\0";
207        let s = parse_porcelain_z(&root(), out);
208        assert_eq!(
209            s.mark(Path::new("/repo/new_name.rs")),
210            Some(GitMark::Renamed)
211        );
212        assert_eq!(s.mark(Path::new("/repo/old_name.rs")), None);
213        assert_eq!(s.mark(Path::new("/repo/other.rs")), Some(GitMark::Modified));
214    }
215
216    #[test]
217    fn conflicts_win_over_modified() {
218        let out = b"UU merge.rs\0";
219        let s = parse_porcelain_z(&root(), out);
220        assert_eq!(s.mark(Path::new("/repo/merge.rs")), Some(GitMark::Conflict));
221        assert!(GitMark::Conflict > GitMark::Modified);
222    }
223
224    #[test]
225    fn ignored_and_empty_input_yield_no_marks() {
226        assert!(parse_porcelain_z(&root(), b"").is_clean());
227        assert!(parse_porcelain_z(&root(), b"!! target/\0").is_clean());
228    }
229
230    #[test]
231    fn status_for_a_missing_directory_is_none() {
232        assert!(status_for(Path::new("/definitely/not/a/real/dir")).is_none());
233    }
234
235    #[test]
236    fn status_for_real_repo_marks_untracked() {
237        let tmp = tempfile::tempdir().unwrap();
238        let ok = Command::new("git")
239            .arg("-C")
240            .arg(tmp.path())
241            .args(["init", "-q"])
242            .status()
243            .map(|s| s.success())
244            .unwrap_or(false);
245        if !ok {
246            return; // git unavailable
247        }
248        std::fs::write(tmp.path().join("hello.txt"), "x").unwrap();
249        let status = status_for(tmp.path()).expect("status");
250        assert_eq!(
251            status.mark(&tmp.path().join("hello.txt")),
252            Some(GitMark::Untracked)
253        );
254    }
255}