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 output = match Command::new("git")
87        .args(["-C", dir, "diff", "--numstat"])
88        .output()
89    {
90        Ok(o) => o,
91        Err(_) => return (0, 0),
92    };
93
94    if !output.status.success() {
95        return (0, 0);
96    }
97
98    let stdout = match String::from_utf8(output.stdout) {
99        Ok(s) => s,
100        Err(_) => return (0, 0),
101    };
102
103    let mut total_added = 0;
104    let mut total_removed = 0;
105
106    for line in stdout.lines() {
107        let parts: Vec<&str> = line.split_whitespace().collect();
108        if parts.len() >= 2 {
109            if let Ok(added) = parts[0].parse::<usize>() {
110                total_added += added;
111            }
112            if let Ok(removed) = parts[1].parse::<usize>() {
113                total_removed += removed;
114            }
115        }
116    }
117
118    (total_added, total_removed)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::fs;
125    use tempfile::TempDir;
126
127    fn create_test_repo() -> TempDir {
128        let dir = TempDir::new().unwrap();
129        let path = dir.path().to_str().unwrap();
130
131        // Initialize git repo
132        std::process::Command::new("git")
133            .args(["init"])
134            .current_dir(path)
135            .output()
136            .unwrap();
137
138        // Configure git user for commits
139        std::process::Command::new("git")
140            .args(["config", "user.email", "test@example.com"])
141            .current_dir(path)
142            .output()
143            .unwrap();
144
145        std::process::Command::new("git")
146            .args(["config", "user.name", "Test User"])
147            .current_dir(path)
148            .output()
149            .unwrap();
150
151        dir
152    }
153
154    #[test]
155    fn test_get_repo_name_https_url() {
156        let dir = create_test_repo();
157        let path = dir.path().to_str().unwrap();
158
159        // Add remote with HTTPS URL
160        std::process::Command::new("git")
161            .args([
162                "remote",
163                "add",
164                "origin",
165                "https://github.com/user/my-repo.git",
166            ])
167            .current_dir(path)
168            .output()
169            .unwrap();
170
171        let result = get_repo_name(path);
172        assert_eq!(result, Some("my-repo".to_string()));
173    }
174
175    #[test]
176    fn test_get_repo_name_ssh_url() {
177        let dir = create_test_repo();
178        let path = dir.path().to_str().unwrap();
179
180        // Add remote with SSH URL
181        std::process::Command::new("git")
182            .args(["remote", "add", "origin", "git@github.com:user/my-repo.git"])
183            .current_dir(path)
184            .output()
185            .unwrap();
186
187        let result = get_repo_name(path);
188        assert_eq!(result, Some("my-repo".to_string()));
189    }
190
191    #[test]
192    fn test_get_repo_name_without_git_extension() {
193        let dir = create_test_repo();
194        let path = dir.path().to_str().unwrap();
195
196        // Add remote without .git extension
197        std::process::Command::new("git")
198            .args(["remote", "add", "origin", "https://github.com/user/my-repo"])
199            .current_dir(path)
200            .output()
201            .unwrap();
202
203        let result = get_repo_name(path);
204        assert_eq!(result, Some("my-repo".to_string()));
205    }
206
207    #[test]
208    fn test_get_repo_name_no_remote() {
209        let dir = create_test_repo();
210        let path = dir.path().to_str().unwrap();
211
212        // No remote configured
213        let result = get_repo_name(path);
214        assert_eq!(result, None);
215    }
216
217    #[test]
218    fn test_get_git_info_no_commits() {
219        let dir = create_test_repo();
220        let path = dir.path().to_str().unwrap();
221
222        let result = get_git_info(path);
223        // Modern git returns a result even without commits (initial branch)
224        // The branch might be "master", "main", or "No commits yet on <branch>"
225        if let Some(info) = result {
226            assert!(!info.branch.is_empty());
227            assert!(!info.is_dirty); // No changes yet
228        }
229        // Some git versions might return None, both are acceptable
230    }
231
232    #[test]
233    fn test_get_git_info_with_commit() {
234        let dir = create_test_repo();
235        let path = dir.path().to_str().unwrap();
236
237        // Create a file and commit
238        fs::write(dir.path().join("test.txt"), "content").unwrap();
239        std::process::Command::new("git")
240            .args(["add", "test.txt"])
241            .current_dir(path)
242            .output()
243            .unwrap();
244        std::process::Command::new("git")
245            .args(["commit", "-m", "Initial commit"])
246            .current_dir(path)
247            .output()
248            .unwrap();
249
250        let result = get_git_info(path).unwrap();
251        assert_eq!(result.branch, "master");
252        assert!(!result.is_dirty);
253        assert_eq!(result.lines_added, 0);
254        assert_eq!(result.lines_removed, 0);
255    }
256
257    #[test]
258    fn test_get_git_info_dirty() {
259        let dir = create_test_repo();
260        let path = dir.path().to_str().unwrap();
261
262        // Create and commit a file
263        fs::write(dir.path().join("test.txt"), "content").unwrap();
264        std::process::Command::new("git")
265            .args(["add", "test.txt"])
266            .current_dir(path)
267            .output()
268            .unwrap();
269        std::process::Command::new("git")
270            .args(["commit", "-m", "Initial commit"])
271            .current_dir(path)
272            .output()
273            .unwrap();
274
275        // Modify the file (dirty working directory)
276        fs::write(dir.path().join("test.txt"), "modified content").unwrap();
277
278        let result = get_git_info(path).unwrap();
279        assert!(result.is_dirty);
280    }
281
282    #[test]
283    fn test_get_git_info_with_diff_stats() {
284        let dir = create_test_repo();
285        let path = dir.path().to_str().unwrap();
286
287        // Create and commit a file
288        fs::write(dir.path().join("test.txt"), "line1\nline2\n").unwrap();
289        std::process::Command::new("git")
290            .args(["add", "test.txt"])
291            .current_dir(path)
292            .output()
293            .unwrap();
294        std::process::Command::new("git")
295            .args(["commit", "-m", "Initial commit"])
296            .current_dir(path)
297            .output()
298            .unwrap();
299
300        // Modify file: add lines, remove lines
301        fs::write(dir.path().join("test.txt"), "line1\nline3\nline4\n").unwrap();
302
303        let result = get_git_info(path).unwrap();
304        assert!(result.lines_added > 0 || result.lines_removed > 0);
305    }
306
307    #[test]
308    fn test_get_git_info_not_a_repo() {
309        let dir = TempDir::new().unwrap();
310        let path = dir.path().to_str().unwrap();
311
312        // Not a git repository
313        let result = get_git_info(path);
314        assert_eq!(result, None);
315    }
316
317    #[test]
318    fn test_get_diff_stats_no_changes() {
319        let dir = create_test_repo();
320        let path = dir.path().to_str().unwrap();
321
322        // Create and commit a file
323        fs::write(dir.path().join("test.txt"), "content").unwrap();
324        std::process::Command::new("git")
325            .args(["add", "test.txt"])
326            .current_dir(path)
327            .output()
328            .unwrap();
329        std::process::Command::new("git")
330            .args(["commit", "-m", "Initial commit"])
331            .current_dir(path)
332            .output()
333            .unwrap();
334
335        let (added, removed) = get_diff_stats(path);
336        assert_eq!(added, 0);
337        assert_eq!(removed, 0);
338    }
339}