claude_code_status_line/
git.rs1use crate::types::GitInfo;
2use std::process::Command;
3
4fn get_repo_name(dir: &str) -> Option<String> {
5 let output = Command::new("git")
6 .args(["-C", dir, "remote", "get-url", "origin"])
7 .output()
8 .ok()?;
9
10 if !output.status.success() {
11 return None;
12 }
13
14 let url = String::from_utf8(output.stdout).ok()?;
15 let url = url.trim();
16
17 let repo = url.rsplit('/').next()?.trim_end_matches(".git").to_string();
22
23 if repo.is_empty() {
24 None
25 } else {
26 Some(repo)
27 }
28}
29
30pub fn get_git_info(dir: &str) -> Option<GitInfo> {
31 let output = match Command::new("git")
32 .args(["-C", dir, "status", "--porcelain", "-b"])
33 .output()
34 {
35 Ok(o) => o,
36 Err(e) => {
37 if std::env::var("STATUSLINE_DEBUG").is_ok() {
38 eprintln!("statusline warning: git not available: {}", e);
39 }
40 return None;
41 }
42 };
43
44 if !output.status.success() {
45 return None;
46 }
47
48 let stdout = String::from_utf8(output.stdout).ok()?;
49 let lines: Vec<&str> = stdout.lines().collect();
50
51 if lines.is_empty() {
52 return None;
53 }
54
55 let branch_line = lines[0];
56 let branch = if let Some(raw) = branch_line.strip_prefix("## ") {
57 if let Some(idx) = raw.find("...") {
58 raw[..idx].to_string()
59 } else if let Some(stripped) = raw.strip_prefix("No commits yet on ") {
60 stripped.to_string()
61 } else if raw.starts_with("HEAD (no branch)") {
62 "HEAD".to_string()
63 } else {
64 raw.to_string()
65 }
66 } else {
67 return None;
68 };
69
70 let is_dirty = lines.len() > 1;
71 let repo_name = get_repo_name(dir);
72
73 let (lines_added, lines_removed) = get_diff_stats(dir);
75
76 Some(GitInfo {
77 branch,
78 is_dirty,
79 repo_name,
80 lines_added,
81 lines_removed,
82 })
83}
84
85fn get_diff_stats(dir: &str) -> (usize, usize) {
86 let output = match Command::new("git")
87 .args(["-C", dir, "diff", "--numstat"])
88 .output()
89 {
90 Ok(o) => o,
91 Err(_) => return (0, 0),
92 };
93
94 if !output.status.success() {
95 return (0, 0);
96 }
97
98 let stdout = match String::from_utf8(output.stdout) {
99 Ok(s) => s,
100 Err(_) => return (0, 0),
101 };
102
103 let mut total_added = 0;
104 let mut total_removed = 0;
105
106 for line in stdout.lines() {
107 let parts: Vec<&str> = line.split_whitespace().collect();
108 if parts.len() >= 2 {
109 if let Ok(added) = parts[0].parse::<usize>() {
110 total_added += added;
111 }
112 if let Ok(removed) = parts[1].parse::<usize>() {
113 total_removed += removed;
114 }
115 }
116 }
117
118 (total_added, total_removed)
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use std::fs;
125 use tempfile::TempDir;
126
127 fn create_test_repo() -> TempDir {
128 let dir = TempDir::new().unwrap();
129 let path = dir.path().to_str().unwrap();
130
131 std::process::Command::new("git")
133 .args(["init"])
134 .current_dir(path)
135 .output()
136 .unwrap();
137
138 std::process::Command::new("git")
140 .args(["config", "user.email", "test@example.com"])
141 .current_dir(path)
142 .output()
143 .unwrap();
144
145 std::process::Command::new("git")
146 .args(["config", "user.name", "Test User"])
147 .current_dir(path)
148 .output()
149 .unwrap();
150
151 dir
152 }
153
154 #[test]
155 fn test_get_repo_name_https_url() {
156 let dir = create_test_repo();
157 let path = dir.path().to_str().unwrap();
158
159 std::process::Command::new("git")
161 .args([
162 "remote",
163 "add",
164 "origin",
165 "https://github.com/user/my-repo.git",
166 ])
167 .current_dir(path)
168 .output()
169 .unwrap();
170
171 let result = get_repo_name(path);
172 assert_eq!(result, Some("my-repo".to_string()));
173 }
174
175 #[test]
176 fn test_get_repo_name_ssh_url() {
177 let dir = create_test_repo();
178 let path = dir.path().to_str().unwrap();
179
180 std::process::Command::new("git")
182 .args(["remote", "add", "origin", "git@github.com:user/my-repo.git"])
183 .current_dir(path)
184 .output()
185 .unwrap();
186
187 let result = get_repo_name(path);
188 assert_eq!(result, Some("my-repo".to_string()));
189 }
190
191 #[test]
192 fn test_get_repo_name_without_git_extension() {
193 let dir = create_test_repo();
194 let path = dir.path().to_str().unwrap();
195
196 std::process::Command::new("git")
198 .args(["remote", "add", "origin", "https://github.com/user/my-repo"])
199 .current_dir(path)
200 .output()
201 .unwrap();
202
203 let result = get_repo_name(path);
204 assert_eq!(result, Some("my-repo".to_string()));
205 }
206
207 #[test]
208 fn test_get_repo_name_no_remote() {
209 let dir = create_test_repo();
210 let path = dir.path().to_str().unwrap();
211
212 let result = get_repo_name(path);
214 assert_eq!(result, None);
215 }
216
217 #[test]
218 fn test_get_git_info_no_commits() {
219 let dir = create_test_repo();
220 let path = dir.path().to_str().unwrap();
221
222 let result = get_git_info(path);
223 if let Some(info) = result {
226 assert!(!info.branch.is_empty());
227 assert!(!info.is_dirty); }
229 }
231
232 #[test]
233 fn test_get_git_info_with_commit() {
234 let dir = create_test_repo();
235 let path = dir.path().to_str().unwrap();
236
237 fs::write(dir.path().join("test.txt"), "content").unwrap();
239 std::process::Command::new("git")
240 .args(["add", "test.txt"])
241 .current_dir(path)
242 .output()
243 .unwrap();
244 std::process::Command::new("git")
245 .args(["commit", "-m", "Initial commit"])
246 .current_dir(path)
247 .output()
248 .unwrap();
249
250 let result = get_git_info(path).unwrap();
251 assert_eq!(result.branch, "master");
252 assert!(!result.is_dirty);
253 assert_eq!(result.lines_added, 0);
254 assert_eq!(result.lines_removed, 0);
255 }
256
257 #[test]
258 fn test_get_git_info_dirty() {
259 let dir = create_test_repo();
260 let path = dir.path().to_str().unwrap();
261
262 fs::write(dir.path().join("test.txt"), "content").unwrap();
264 std::process::Command::new("git")
265 .args(["add", "test.txt"])
266 .current_dir(path)
267 .output()
268 .unwrap();
269 std::process::Command::new("git")
270 .args(["commit", "-m", "Initial commit"])
271 .current_dir(path)
272 .output()
273 .unwrap();
274
275 fs::write(dir.path().join("test.txt"), "modified content").unwrap();
277
278 let result = get_git_info(path).unwrap();
279 assert!(result.is_dirty);
280 }
281
282 #[test]
283 fn test_get_git_info_with_diff_stats() {
284 let dir = create_test_repo();
285 let path = dir.path().to_str().unwrap();
286
287 fs::write(dir.path().join("test.txt"), "line1\nline2\n").unwrap();
289 std::process::Command::new("git")
290 .args(["add", "test.txt"])
291 .current_dir(path)
292 .output()
293 .unwrap();
294 std::process::Command::new("git")
295 .args(["commit", "-m", "Initial commit"])
296 .current_dir(path)
297 .output()
298 .unwrap();
299
300 fs::write(dir.path().join("test.txt"), "line1\nline3\nline4\n").unwrap();
302
303 let result = get_git_info(path).unwrap();
304 assert!(result.lines_added > 0 || result.lines_removed > 0);
305 }
306
307 #[test]
308 fn test_get_git_info_not_a_repo() {
309 let dir = TempDir::new().unwrap();
310 let path = dir.path().to_str().unwrap();
311
312 let result = get_git_info(path);
314 assert_eq!(result, None);
315 }
316
317 #[test]
318 fn test_get_diff_stats_no_changes() {
319 let dir = create_test_repo();
320 let path = dir.path().to_str().unwrap();
321
322 fs::write(dir.path().join("test.txt"), "content").unwrap();
324 std::process::Command::new("git")
325 .args(["add", "test.txt"])
326 .current_dir(path)
327 .output()
328 .unwrap();
329 std::process::Command::new("git")
330 .args(["commit", "-m", "Initial commit"])
331 .current_dir(path)
332 .output()
333 .unwrap();
334
335 let (added, removed) = get_diff_stats(path);
336 assert_eq!(added, 0);
337 assert_eq!(removed, 0);
338 }
339}