Skip to main content

codelens_core/git/
mod.rs

1//! Git repository integration via CLI.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use serde::Serialize;
8
9use crate::error::{Error, Result};
10
11/// Git repository client using system git CLI.
12pub struct GitClient {
13    repo_path: PathBuf,
14}
15
16/// File change frequency data.
17#[derive(Debug, Clone, Serialize)]
18pub struct FileChurn {
19    pub path: PathBuf,
20    pub commits: usize,
21    pub lines_added: usize,
22    pub lines_deleted: usize,
23}
24
25/// Repository metadata.
26#[derive(Debug, Clone, Serialize)]
27pub struct RepoInfo {
28    pub branch: Option<String>,
29    pub commit: Option<String>,
30    pub author: Option<String>,
31    pub date: Option<String>,
32}
33
34impl GitClient {
35    /// Detect if the given path is inside a git repository.
36    /// Returns a GitClient rooted at the repository root.
37    pub fn detect(path: &Path) -> Result<Self> {
38        let output = Command::new("git")
39            .args(["rev-parse", "--show-toplevel"])
40            .current_dir(path)
41            .output()
42            .map_err(|e| Error::GitError {
43                message: format!("failed to execute git: {e}"),
44            })?;
45
46        if !output.status.success() {
47            return Err(Error::NotGitRepo {
48                path: path.to_path_buf(),
49            });
50        }
51
52        let repo_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
53
54        Ok(Self {
55            repo_path: PathBuf::from(repo_path),
56        })
57    }
58
59    /// Get the repository root path.
60    pub fn repo_path(&self) -> &Path {
61        &self.repo_path
62    }
63
64    /// Get repository metadata (branch, last commit).
65    pub fn repo_info(&self) -> Result<RepoInfo> {
66        let branch = self.run_git(&["rev-parse", "--abbrev-ref", "HEAD"]).ok();
67        let commit = self.run_git(&["rev-parse", "--short", "HEAD"]).ok();
68        let author = self.run_git(&["log", "-1", "--format=%an"]).ok();
69        let date = self.run_git(&["log", "-1", "--format=%ai"]).ok();
70
71        Ok(RepoInfo {
72            branch,
73            commit,
74            author,
75            date,
76        })
77    }
78
79    /// Get file change frequency within the given time window.
80    ///
81    /// `since` is passed directly to `git log --since`, e.g. "90 days ago", "2025-01-01".
82    pub fn file_churn(&self, since: &str) -> Result<Vec<FileChurn>> {
83        let output = Command::new("git")
84            .args([
85                "log",
86                "--numstat",
87                "--format=%H",
88                &format!("--since={since}"),
89            ])
90            .current_dir(&self.repo_path)
91            .output()
92            .map_err(|e| Error::GitError {
93                message: format!("failed to execute git log: {e}"),
94            })?;
95
96        if !output.status.success() {
97            // An empty repository (no commits yet) causes git log to fail.
98            // Treat this as an empty result rather than an error.
99            if self.run_git(&["rev-parse", "HEAD"]).is_err() {
100                return Ok(vec![]);
101            }
102            let stderr = String::from_utf8_lossy(&output.stderr);
103            return Err(Error::GitError {
104                message: format!("git log failed: {stderr}"),
105            });
106        }
107
108        let stdout = String::from_utf8_lossy(&output.stdout);
109        Ok(parse_numstat(&stdout))
110    }
111
112    /// Get total commit count in the given time window.
113    pub fn commit_count(&self, since: &str) -> Result<usize> {
114        let output = self.run_git(&["rev-list", "--count", "HEAD", &format!("--since={since}")])?;
115        Ok(output.parse::<usize>().unwrap_or(0))
116    }
117
118    fn run_git(&self, args: &[&str]) -> Result<String> {
119        let output = Command::new("git")
120            .args(args)
121            .current_dir(&self.repo_path)
122            .output()
123            .map_err(|e| Error::GitError {
124                message: format!("failed to execute git: {e}"),
125            })?;
126
127        if !output.status.success() {
128            let stderr = String::from_utf8_lossy(&output.stderr);
129            return Err(Error::GitError {
130                message: stderr.trim().to_string(),
131            });
132        }
133
134        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
135    }
136}
137
138/// Parse `git log --numstat` output into per-file churn data.
139fn parse_numstat(output: &str) -> Vec<FileChurn> {
140    let mut file_map: HashMap<PathBuf, (usize, usize, usize)> = HashMap::new();
141
142    for line in output.lines() {
143        let line = line.trim();
144        if line.is_empty() {
145            continue;
146        }
147
148        let parts: Vec<&str> = line.split('\t').collect();
149        if parts.len() == 3 {
150            let added = parts[0].parse::<usize>().unwrap_or(0);
151            let deleted = parts[1].parse::<usize>().unwrap_or(0);
152            let path = PathBuf::from(parts[2]);
153
154            let entry = file_map.entry(path).or_insert((0, 0, 0));
155            entry.0 += 1;
156            entry.1 += added;
157            entry.2 += deleted;
158        }
159    }
160
161    let mut churns: Vec<FileChurn> = file_map
162        .into_iter()
163        .map(|(path, (commits, added, deleted))| FileChurn {
164            path,
165            commits,
166            lines_added: added,
167            lines_deleted: deleted,
168        })
169        .collect();
170
171    churns.sort_by(|a, b| b.commits.cmp(&a.commits));
172    churns
173}
174
175/// Parse a human-friendly duration string into a git --since compatible string.
176///
177/// Supported formats: "30d", "4w", "6m", "1y", "2025-01-01"
178pub fn parse_since(input: &str) -> String {
179    let input = input.trim();
180
181    if input.len() == 10 && input.chars().nth(4) == Some('-') {
182        return input.to_string();
183    }
184
185    if let Some(num_str) = input.strip_suffix('d') {
186        if let Ok(n) = num_str.parse::<u32>() {
187            return format!("{n} days ago");
188        }
189    }
190    if let Some(num_str) = input.strip_suffix('w') {
191        if let Ok(n) = num_str.parse::<u32>() {
192            return format!("{} days ago", n * 7);
193        }
194    }
195    if let Some(num_str) = input.strip_suffix('m') {
196        if let Ok(n) = num_str.parse::<u32>() {
197            return format!("{n} months ago");
198        }
199    }
200    if let Some(num_str) = input.strip_suffix('y') {
201        if let Ok(n) = num_str.parse::<u32>() {
202            return format!("{n} years ago");
203        }
204    }
205
206    input.to_string()
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_parse_numstat_empty() {
215        let result = parse_numstat("");
216        assert!(result.is_empty());
217    }
218
219    #[test]
220    fn test_parse_numstat_single_commit() {
221        let input = "abc1234\n5\t3\tsrc/main.rs\n2\t1\tsrc/lib.rs\n";
222        let result = parse_numstat(input);
223        assert_eq!(result.len(), 2);
224        let main = result
225            .iter()
226            .find(|f| f.path == Path::new("src/main.rs"))
227            .unwrap();
228        assert_eq!(main.commits, 1);
229        assert_eq!(main.lines_added, 5);
230        assert_eq!(main.lines_deleted, 3);
231    }
232
233    #[test]
234    fn test_parse_numstat_multiple_commits_same_file() {
235        let input = "abc1234\n5\t3\tsrc/main.rs\n\ndef5678\n10\t2\tsrc/main.rs\n";
236        let result = parse_numstat(input);
237        assert_eq!(result.len(), 1);
238        let main = &result[0];
239        assert_eq!(main.commits, 2);
240        assert_eq!(main.lines_added, 15);
241        assert_eq!(main.lines_deleted, 5);
242    }
243
244    #[test]
245    fn test_parse_numstat_binary_files() {
246        let input = "abc1234\n-\t-\timage.png\n5\t3\tsrc/main.rs\n";
247        let result = parse_numstat(input);
248        let png = result
249            .iter()
250            .find(|f| f.path == Path::new("image.png"))
251            .unwrap();
252        assert_eq!(png.commits, 1);
253        assert_eq!(png.lines_added, 0);
254    }
255
256    #[test]
257    fn test_parse_since_days() {
258        assert_eq!(parse_since("30d"), "30 days ago");
259        assert_eq!(parse_since("7d"), "7 days ago");
260    }
261
262    #[test]
263    fn test_parse_since_weeks() {
264        assert_eq!(parse_since("4w"), "28 days ago");
265    }
266
267    #[test]
268    fn test_parse_since_months() {
269        assert_eq!(parse_since("6m"), "6 months ago");
270    }
271
272    #[test]
273    fn test_parse_since_years() {
274        assert_eq!(parse_since("1y"), "1 years ago");
275    }
276
277    #[test]
278    fn test_parse_since_date() {
279        assert_eq!(parse_since("2025-01-01"), "2025-01-01");
280    }
281
282    #[test]
283    fn test_parse_since_passthrough() {
284        assert_eq!(parse_since("3 months ago"), "3 months ago");
285    }
286
287    #[test]
288    fn test_detect_in_git_repo() {
289        let temp = tempfile::TempDir::new().unwrap();
290        Command::new("git")
291            .args(["init"])
292            .current_dir(temp.path())
293            .output()
294            .unwrap();
295        let client = GitClient::detect(temp.path());
296        assert!(client.is_ok());
297    }
298
299    #[test]
300    fn test_detect_not_git_repo() {
301        let temp = tempfile::TempDir::new().unwrap();
302        let result = GitClient::detect(temp.path());
303        assert!(result.is_err());
304    }
305
306    #[test]
307    fn test_file_churn_empty_repo() {
308        let temp = tempfile::TempDir::new().unwrap();
309        Command::new("git")
310            .args(["init"])
311            .current_dir(temp.path())
312            .output()
313            .unwrap();
314        Command::new("git")
315            .args(["config", "user.email", "test@test.com"])
316            .current_dir(temp.path())
317            .output()
318            .unwrap();
319        Command::new("git")
320            .args(["config", "user.name", "Test"])
321            .current_dir(temp.path())
322            .output()
323            .unwrap();
324        let client = GitClient::detect(temp.path()).unwrap();
325        let churns = client.file_churn("90 days ago").unwrap();
326        assert!(churns.is_empty());
327    }
328
329    #[test]
330    fn test_file_churn_with_commits() {
331        let temp = tempfile::TempDir::new().unwrap();
332        Command::new("git")
333            .args(["init"])
334            .current_dir(temp.path())
335            .output()
336            .unwrap();
337        Command::new("git")
338            .args(["config", "user.email", "test@test.com"])
339            .current_dir(temp.path())
340            .output()
341            .unwrap();
342        Command::new("git")
343            .args(["config", "user.name", "Test"])
344            .current_dir(temp.path())
345            .output()
346            .unwrap();
347
348        std::fs::write(temp.path().join("hello.rs"), "fn main() {}\n").unwrap();
349        Command::new("git")
350            .args(["add", "."])
351            .current_dir(temp.path())
352            .output()
353            .unwrap();
354        Command::new("git")
355            .args(["commit", "-m", "init"])
356            .current_dir(temp.path())
357            .output()
358            .unwrap();
359
360        std::fs::write(
361            temp.path().join("hello.rs"),
362            "fn main() {\n    println!(\"hello\");\n}\n",
363        )
364        .unwrap();
365        Command::new("git")
366            .args(["add", "."])
367            .current_dir(temp.path())
368            .output()
369            .unwrap();
370        Command::new("git")
371            .args(["commit", "-m", "update"])
372            .current_dir(temp.path())
373            .output()
374            .unwrap();
375
376        let client = GitClient::detect(temp.path()).unwrap();
377        let churns = client.file_churn("90 days ago").unwrap();
378        assert_eq!(churns.len(), 1);
379        assert_eq!(churns[0].path, PathBuf::from("hello.rs"));
380        assert_eq!(churns[0].commits, 2);
381    }
382}