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