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 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    // Get diff stats (lines added/removed)
71    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        // Initialize git repo
141        std::process::Command::new("git")
142            .args(["init"])
143            .current_dir(path)
144            .output()
145            .unwrap();
146
147        // Configure git user for commits
148        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        // Add remote with HTTPS URL
169        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        // Add remote with SSH URL
190        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        // Add remote without .git extension
206        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        // No remote configured
222        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        // Modern git returns a result even without commits (initial branch)
233        // The branch might be "master", "main", or "No commits yet on <branch>"
234        if let Some(info) = result {
235            assert!(!info.branch.is_empty());
236            assert!(!info.is_dirty); // No changes yet
237        }
238        // Some git versions might return None, both are acceptable
239    }
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        // Create a file and commit
247        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        // Create and commit a file
272        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        // Modify the file (dirty working directory)
285        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        // Create and commit a file
297        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        // Modify file: add lines, remove lines
310        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        // Not a git repository
322        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        // Create and commit a file
332        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}