Skip to main content

claude_code_status_line/
git.rs

1use 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    // Extract repo name from various URL formats:
18    // https://github.com/user/repo.git -> repo
19    // git@github.com:user/repo.git -> repo
20    // https://github.com/user/repo -> repo
21    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    // Get diff stats (lines added/removed)
74    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        // Initialize git repo
144        std::process::Command::new("git")
145            .args(["init"])
146            .current_dir(path)
147            .output()
148            .unwrap();
149
150        // Configure git user for commits
151        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        // Add remote with HTTPS URL
172        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        // Add remote with SSH URL
193        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        // Add remote without .git extension
209        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        // No remote configured
225        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        // Modern git returns a result even without commits (initial branch)
236        // The branch might be "master", "main", or "No commits yet on <branch>"
237        if let Some(info) = result {
238            assert!(!info.branch.is_empty());
239            assert!(!info.is_dirty); // No changes yet
240        }
241        // Some git versions might return None, both are acceptable
242    }
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        // Create a file and commit
250        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        // Create and commit a file
275        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        // Modify the file (dirty working directory)
288        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        // Create and commit a file
300        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        // Modify file: add lines, remove lines
313        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        // Not a git repository
325        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        // Create and commit a file
335        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}