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 (unstaged_added, unstaged_removed) =
87 get_diff_stats_for_command(dir, &["diff", "--numstat"]);
88 let (staged_added, staged_removed) =
89 get_diff_stats_for_command(dir, &["diff", "--cached", "--numstat"]);
90
91 (
92 unstaged_added + staged_added,
93 unstaged_removed + staged_removed,
94 )
95}
96
97fn get_diff_stats_for_command(dir: &str, args: &[&str]) -> (usize, usize) {
98 let mut cmd_args = vec!["-C", dir];
99 cmd_args.extend_from_slice(args);
100
101 let output = match Command::new("git").args(&cmd_args).output() {
102 Ok(o) => o,
103 Err(_) => return (0, 0),
104 };
105
106 if !output.status.success() {
107 return (0, 0);
108 }
109
110 let stdout = match String::from_utf8(output.stdout) {
111 Ok(s) => s,
112 Err(_) => return (0, 0),
113 };
114
115 let mut total_added = 0;
116 let mut total_removed = 0;
117
118 for line in stdout.lines() {
119 let parts: Vec<&str> = line.split_whitespace().collect();
120 if parts.len() >= 2 {
121 if let Ok(added) = parts[0].parse::<usize>() {
122 total_added += added;
123 }
124 if let Ok(removed) = parts[1].parse::<usize>() {
125 total_removed += removed;
126 }
127 }
128 }
129
130 (total_added, total_removed)
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use std::fs;
137 use tempfile::TempDir;
138
139 fn create_test_repo() -> TempDir {
140 let dir = TempDir::new().unwrap();
141 let path = dir.path().to_str().unwrap();
142
143 std::process::Command::new("git")
145 .args(["init"])
146 .current_dir(path)
147 .output()
148 .unwrap();
149
150 std::process::Command::new("git")
152 .args(["config", "user.email", "test@example.com"])
153 .current_dir(path)
154 .output()
155 .unwrap();
156
157 std::process::Command::new("git")
158 .args(["config", "user.name", "Test User"])
159 .current_dir(path)
160 .output()
161 .unwrap();
162
163 dir
164 }
165
166 #[test]
167 fn test_get_repo_name_https_url() {
168 let dir = create_test_repo();
169 let path = dir.path().to_str().unwrap();
170
171 std::process::Command::new("git")
173 .args([
174 "remote",
175 "add",
176 "origin",
177 "https://github.com/user/my-repo.git",
178 ])
179 .current_dir(path)
180 .output()
181 .unwrap();
182
183 let result = get_repo_name(path);
184 assert_eq!(result, Some("my-repo".to_string()));
185 }
186
187 #[test]
188 fn test_get_repo_name_ssh_url() {
189 let dir = create_test_repo();
190 let path = dir.path().to_str().unwrap();
191
192 std::process::Command::new("git")
194 .args(["remote", "add", "origin", "git@github.com:user/my-repo.git"])
195 .current_dir(path)
196 .output()
197 .unwrap();
198
199 let result = get_repo_name(path);
200 assert_eq!(result, Some("my-repo".to_string()));
201 }
202
203 #[test]
204 fn test_get_repo_name_without_git_extension() {
205 let dir = create_test_repo();
206 let path = dir.path().to_str().unwrap();
207
208 std::process::Command::new("git")
210 .args(["remote", "add", "origin", "https://github.com/user/my-repo"])
211 .current_dir(path)
212 .output()
213 .unwrap();
214
215 let result = get_repo_name(path);
216 assert_eq!(result, Some("my-repo".to_string()));
217 }
218
219 #[test]
220 fn test_get_repo_name_no_remote() {
221 let dir = create_test_repo();
222 let path = dir.path().to_str().unwrap();
223
224 let result = get_repo_name(path);
226 assert_eq!(result, None);
227 }
228
229 #[test]
230 fn test_get_git_info_no_commits() {
231 let dir = create_test_repo();
232 let path = dir.path().to_str().unwrap();
233
234 let result = get_git_info(path);
235 if let Some(info) = result {
238 assert!(!info.branch.is_empty());
239 assert!(!info.is_dirty); }
241 }
243
244 #[test]
245 fn test_get_git_info_with_commit() {
246 let dir = create_test_repo();
247 let path = dir.path().to_str().unwrap();
248
249 fs::write(dir.path().join("test.txt"), "content").unwrap();
251 std::process::Command::new("git")
252 .args(["add", "test.txt"])
253 .current_dir(path)
254 .output()
255 .unwrap();
256 std::process::Command::new("git")
257 .args(["commit", "-m", "Initial commit"])
258 .current_dir(path)
259 .output()
260 .unwrap();
261
262 let result = get_git_info(path).unwrap();
263 assert_eq!(result.branch, "master");
264 assert!(!result.is_dirty);
265 assert_eq!(result.lines_added, 0);
266 assert_eq!(result.lines_removed, 0);
267 }
268
269 #[test]
270 fn test_get_git_info_dirty() {
271 let dir = create_test_repo();
272 let path = dir.path().to_str().unwrap();
273
274 fs::write(dir.path().join("test.txt"), "content").unwrap();
276 std::process::Command::new("git")
277 .args(["add", "test.txt"])
278 .current_dir(path)
279 .output()
280 .unwrap();
281 std::process::Command::new("git")
282 .args(["commit", "-m", "Initial commit"])
283 .current_dir(path)
284 .output()
285 .unwrap();
286
287 fs::write(dir.path().join("test.txt"), "modified content").unwrap();
289
290 let result = get_git_info(path).unwrap();
291 assert!(result.is_dirty);
292 }
293
294 #[test]
295 fn test_get_git_info_with_diff_stats() {
296 let dir = create_test_repo();
297 let path = dir.path().to_str().unwrap();
298
299 fs::write(dir.path().join("test.txt"), "line1\nline2\n").unwrap();
301 std::process::Command::new("git")
302 .args(["add", "test.txt"])
303 .current_dir(path)
304 .output()
305 .unwrap();
306 std::process::Command::new("git")
307 .args(["commit", "-m", "Initial commit"])
308 .current_dir(path)
309 .output()
310 .unwrap();
311
312 fs::write(dir.path().join("test.txt"), "line1\nline3\nline4\n").unwrap();
314
315 let result = get_git_info(path).unwrap();
316 assert!(result.lines_added > 0 || result.lines_removed > 0);
317 }
318
319 #[test]
320 fn test_get_git_info_not_a_repo() {
321 let dir = TempDir::new().unwrap();
322 let path = dir.path().to_str().unwrap();
323
324 let result = get_git_info(path);
326 assert_eq!(result, None);
327 }
328
329 #[test]
330 fn test_get_diff_stats_no_changes() {
331 let dir = create_test_repo();
332 let path = dir.path().to_str().unwrap();
333
334 fs::write(dir.path().join("test.txt"), "content").unwrap();
336 std::process::Command::new("git")
337 .args(["add", "test.txt"])
338 .current_dir(path)
339 .output()
340 .unwrap();
341 std::process::Command::new("git")
342 .args(["commit", "-m", "Initial commit"])
343 .current_dir(path)
344 .output()
345 .unwrap();
346
347 let (added, removed) = get_diff_stats(path);
348 assert_eq!(added, 0);
349 assert_eq!(removed, 0);
350 }
351}