Skip to main content

thndrs_lib/cli/
git.rs

1//! Reads `git status --porcelain=v1` for the workspace subtree and reduces it
2//! to the branch name plus added, modified, and deleted file counts.
3//!
4//! The collector uses read-only git commands with optional locks disabled.
5
6use std::{path::Path, process::Command};
7
8/// A changed-file category reported by `git status`.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub enum GitStatusKind {
11    Added,
12    Deleted,
13    Modified,
14}
15
16impl GitStatusKind {
17    fn parse(c: &str) -> GitStatusKind {
18        match c {
19            "??" => GitStatusKind::Added,
20            code => {
21                if code.contains('U') {
22                    return GitStatusKind::Modified;
23                }
24                if code.contains('A') && !code.contains('D') {
25                    return GitStatusKind::Added;
26                }
27                if code.contains('D') && !code.contains('A') {
28                    return GitStatusKind::Deleted;
29                }
30                GitStatusKind::Modified
31            }
32        }
33    }
34}
35
36/// One parsed porcelain status item.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct GitStatusItem {
39    pub file: String,
40    pub code: String,
41    pub status: GitStatusKind,
42}
43
44/// Bounded semantic summary of the workspace git state.
45#[derive(Clone, Debug, Eq, PartialEq, Default)]
46pub struct GitStatusSummary {
47    pub branch: Option<String>,
48    pub added: usize,
49    pub modified: usize,
50    pub deleted: usize,
51}
52
53impl GitStatusSummary {
54    pub fn from_items(branch: Option<String>, items: &[GitStatusItem]) -> Self {
55        let mut summary = Self { branch, ..Default::default() };
56        for item in items {
57            match item.status {
58                GitStatusKind::Added => summary.added += 1,
59                GitStatusKind::Deleted => summary.deleted += 1,
60                GitStatusKind::Modified => summary.modified += 1,
61            }
62        }
63        summary
64    }
65
66    /// Format the summary for the compact direct-renderer status line.
67    pub fn display(&self) -> String {
68        let branch = self.branch.as_deref().unwrap_or("detached");
69        if self.added == 0 && self.modified == 0 && self.deleted == 0 {
70            return format!("git: {branch} clean");
71        }
72        format!("git: {branch} +{} ~{} -{}", self.added, self.modified, self.deleted)
73    }
74}
75
76/// Collect a semantic status summary without changing the workspace.
77pub fn collect(cwd: &Path) -> Option<GitStatusSummary> {
78    if !git_success(cwd, &["rev-parse", "--is-inside-work-tree"]) {
79        return None;
80    }
81    let branch = git_output(cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"])
82        .and_then(|branch| (!branch.is_empty()).then_some(branch));
83    let output = git_output(
84        cwd,
85        &[
86            "status",
87            "--porcelain=v1",
88            "--untracked-files=all",
89            "--no-renames",
90            "-z",
91            "--",
92            ".",
93        ],
94    )?;
95
96    Some(GitStatusSummary::from_items(branch, &parse_status_output(&output)))
97}
98
99fn git_success(cwd: &Path, args: &[&str]) -> bool {
100    Command::new("git")
101        .args(git_config_args())
102        .args(args)
103        .current_dir(cwd)
104        .output()
105        .is_ok_and(|output| output.status.success())
106}
107
108fn git_output(cwd: &Path, args: &[&str]) -> Option<String> {
109    let output = Command::new("git")
110        .args(git_config_args())
111        .args(args)
112        .current_dir(cwd)
113        .output()
114        .ok()?;
115    output
116        .status
117        .success()
118        .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
119}
120
121fn git_config_args() -> [&'static str; 9] {
122    [
123        "--no-optional-locks",
124        "-c",
125        "core.autocrlf=false",
126        "-c",
127        "core.fsmonitor=false",
128        "-c",
129        "core.quotepath=false",
130        "-c",
131        "status.relativePaths=true",
132    ]
133}
134
135fn parse_status_output(output: &str) -> Vec<GitStatusItem> {
136    output
137        .split('\0')
138        .filter_map(|item| {
139            if item.len() < 4 {
140                return None;
141            }
142            let code = item[..2].to_string();
143            let file = item[3..].to_string();
144            if file.is_empty() {
145                return None;
146            }
147            Some(GitStatusItem { status: GitStatusKind::parse(&code), code, file })
148        })
149        .collect()
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use std::path::Path;
156
157    #[test]
158    fn parse_porcelain_status_items() {
159        let items = parse_status_output(" M src/main.rs\0?? new.txt\0D  old.txt\0");
160        assert_eq!(
161            items,
162            vec![
163                GitStatusItem {
164                    file: "src/main.rs".to_string(),
165                    code: " M".to_string(),
166                    status: GitStatusKind::Modified,
167                },
168                GitStatusItem { file: "new.txt".to_string(), code: "??".to_string(), status: GitStatusKind::Added },
169                GitStatusItem { file: "old.txt".to_string(), code: "D ".to_string(), status: GitStatusKind::Deleted },
170            ]
171        );
172    }
173
174    #[test]
175    fn summary_display_shows_clean_or_counts() {
176        assert_eq!(
177            GitStatusSummary::from_items(Some("main".to_string()), &[]).display(),
178            "git: main clean"
179        );
180        let items = parse_status_output(" M src/main.rs\0?? new.txt\0D  old.txt\0");
181        assert_eq!(
182            GitStatusSummary::from_items(Some("main".to_string()), &items).display(),
183            "git: main +1 ~1 -1"
184        );
185    }
186
187    #[test]
188    fn collect_returns_none_outside_git_repo() {
189        let dir = tempfile::tempdir().expect("temp dir");
190        assert_eq!(collect(dir.path()), None);
191    }
192
193    #[test]
194    fn collect_reports_clean_temp_repo() {
195        let dir = temp_git_repo();
196        let summary = collect(dir.path()).expect("git summary");
197        assert_eq!(summary.added, 0);
198        assert_eq!(summary.modified, 0);
199        assert_eq!(summary.deleted, 0);
200        assert!(summary.branch.is_some(), "clean repo should report current branch");
201    }
202
203    #[test]
204    fn collect_reports_untracked_modified_and_deleted_files() {
205        let dir = temp_git_repo();
206        std::fs::write(dir.path().join("tracked.txt"), "dirty\n").expect("modify tracked file");
207        std::fs::write(dir.path().join("new.txt"), "new\n").expect("write untracked file");
208        std::fs::remove_file(dir.path().join("deleted.txt")).expect("delete tracked file");
209
210        let summary = collect(dir.path()).expect("git summary");
211        assert_eq!(summary.added, 1);
212        assert_eq!(summary.modified, 1);
213        assert_eq!(summary.deleted, 1);
214    }
215
216    fn temp_git_repo() -> tempfile::TempDir {
217        let dir = tempfile::tempdir().expect("temp git dir");
218        git(dir.path(), &["init"]);
219        git(dir.path(), &["config", "user.email", "test@example.com"]);
220        git(dir.path(), &["config", "user.name", "Test User"]);
221        std::fs::write(dir.path().join("tracked.txt"), "clean\n").expect("write tracked file");
222        std::fs::write(dir.path().join("deleted.txt"), "delete me\n").expect("write deleted file");
223        git(dir.path(), &["add", "."]);
224        git(dir.path(), &["commit", "-m", "initial"]);
225        dir
226    }
227
228    fn git(cwd: &Path, args: &[&str]) {
229        let output = Command::new("git")
230            .args(args)
231            .current_dir(cwd)
232            .output()
233            .unwrap_or_else(|err| panic!("git {args:?} failed to start: {err}"));
234        assert!(
235            output.status.success(),
236            "git {args:?} failed: {}",
237            String::from_utf8_lossy(&output.stderr)
238        );
239    }
240}