1use std::{path::Path, process::Command};
7
8#[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#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct GitStatusItem {
39 pub file: String,
40 pub code: String,
41 pub status: GitStatusKind,
42}
43
44#[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 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
76pub 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}