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