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() { 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    // Get diff stats (lines added/removed)
67    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        // Initialize git repo
137        std::process::Command::new("git")
138            .args(["init"])
139            .current_dir(path)
140            .output()
141            .unwrap();
142
143        // Configure git user for commits
144        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        // Add remote with HTTPS URL
165        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        // Add remote with SSH URL
186        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        // Add remote without .git extension
202        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        // No remote configured
218        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        // Modern git returns a result even without commits (initial branch)
229        // The branch might be "master", "main", or "No commits yet on <branch>"
230        if let Some(info) = result {
231            assert!(!info.branch.is_empty());
232            assert!(!info.is_dirty); // No changes yet
233        }
234        // Some git versions might return None, both are acceptable
235    }
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        // Create a file and commit
243        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        // Create and commit a file
268        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        // Modify the file (dirty working directory)
281        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        // Create and commit a file
293        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        // Modify file: add lines, remove lines
306        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        // Not a git repository
318        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        // Create and commit a file
328        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}