Skip to main content

codelens_engine/
git.rs

1use crate::project::ProjectRoot;
2use anyhow::{Result, bail};
3use serde::Serialize;
4use std::process::Command;
5
6#[derive(Debug, Clone, Serialize)]
7pub struct ChangedFile {
8    pub file: String,
9    pub status: String,
10}
11
12#[derive(Debug, Clone, Serialize)]
13pub struct DiffSymbol {
14    pub file: String,
15    pub status: String,
16    pub symbols: Vec<DiffSymbolEntry>,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct DiffSymbolEntry {
21    pub name: String,
22    pub kind: String,
23    pub line: usize,
24}
25
26fn run_git(project: &ProjectRoot, args: &[&str]) -> Result<String> {
27    let output = Command::new("git")
28        .args(args)
29        .current_dir(project.as_path())
30        .output()?;
31
32    if !output.status.success() {
33        let stderr = String::from_utf8_lossy(&output.stderr);
34        if stderr.contains("not a git repository") || stderr.contains("fatal:") {
35            bail!("not a git repository: {}", project.as_path().display());
36        }
37        bail!("git error: {}", stderr.trim());
38    }
39
40    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
41}
42
43fn parse_name_status(output: &str) -> Vec<ChangedFile> {
44    output
45        .lines()
46        .filter_map(|line| {
47            let mut parts = line.splitn(2, '\t');
48            let status = parts.next()?.trim().to_owned();
49            let file = parts.next()?.trim().to_owned();
50            if status.is_empty() || file.is_empty() {
51                return None;
52            }
53            // For renames (R100\told\tnew), take just the status prefix letter
54            let status_char = status.chars().next()?.to_string();
55            Some(ChangedFile {
56                file,
57                status: status_char,
58            })
59        })
60        .collect()
61}
62
63fn dedup_files(files: Vec<ChangedFile>) -> Vec<ChangedFile> {
64    let mut seen = std::collections::HashSet::new();
65    files
66        .into_iter()
67        .filter(|f| seen.insert(f.file.clone()))
68        .collect()
69}
70
71pub fn get_changed_files(
72    project: &ProjectRoot,
73    git_ref: Option<&str>,
74    include_untracked: bool,
75) -> Result<Vec<ChangedFile>> {
76    // Verify it's a git repo first
77    run_git(project, &["rev-parse", "--git-dir"])?;
78
79    let ref_target = git_ref.unwrap_or("HEAD");
80    let mut all_files: Vec<ChangedFile> = Vec::new();
81
82    // Files changed relative to git_ref (committed diff)
83    match run_git(project, &["diff", "--name-status", ref_target]) {
84        Ok(output) => all_files.extend(parse_name_status(&output)),
85        Err(e) => {
86            // If HEAD doesn't exist yet (empty repo), ignore
87            let msg = e.to_string();
88            if !msg.contains("unknown revision") && !msg.contains("ambiguous argument") {
89                return Err(e);
90            }
91        }
92    }
93
94    // Unstaged changes (working tree vs index)
95    if let Ok(output) = run_git(project, &["diff", "--name-status"]) {
96        all_files.extend(parse_name_status(&output));
97    }
98
99    // Staged changes (index vs HEAD)
100    if let Ok(output) = run_git(project, &["diff", "--name-status", "--cached"]) {
101        all_files.extend(parse_name_status(&output));
102    }
103
104    // Untracked files
105    if include_untracked
106        && let Ok(output) = run_git(project, &["ls-files", "--others", "--exclude-standard"])
107    {
108        for line in output.lines() {
109            let file = line.trim().to_owned();
110            if !file.is_empty() {
111                all_files.push(ChangedFile {
112                    file,
113                    status: "?".to_owned(),
114                });
115            }
116        }
117    }
118
119    Ok(dedup_files(all_files))
120}
121
122/// Check whether the diff for a single file is additive-only (no deleted lines).
123/// Returns `"additive"` if the file has 0 deleted lines (new exports, new code),
124/// `"breaking"` if it was deleted, or `"mixed"` otherwise.
125pub fn classify_change_kind(project: &ProjectRoot, file_path: &str) -> String {
126    // New/untracked files are always additive
127    let status = run_git(project, &["status", "--porcelain", "--", file_path]).unwrap_or_default();
128    let status_char = status.trim().chars().next().unwrap_or('M');
129    if status_char == '?' || status_char == 'A' {
130        return "additive".to_owned();
131    }
132    if status_char == 'D' {
133        return "breaking".to_owned();
134    }
135    // For modified files: check numstat (additions/deletions)
136    let numstat =
137        run_git(project, &["diff", "--numstat", "HEAD", "--", file_path]).unwrap_or_default();
138    if let Some(line) = numstat.lines().next() {
139        let parts: Vec<&str> = line.split('\t').collect();
140        if parts.len() >= 2 {
141            let deletions: u64 = parts[1].parse().unwrap_or(1);
142            if deletions == 0 {
143                return "additive".to_owned();
144            }
145        }
146    }
147    "mixed".to_owned()
148}
149
150pub fn get_diff_symbols(project: &ProjectRoot, git_ref: Option<&str>) -> Result<Vec<DiffSymbol>> {
151    use crate::symbols::{SymbolKind, get_symbols_overview};
152
153    let changed = get_changed_files(project, git_ref, false)?;
154    let mut result = Vec::new();
155
156    for cf in changed {
157        // Skip deleted files — no symbols to parse
158        if cf.status == "D" {
159            result.push(DiffSymbol {
160                file: cf.file,
161                status: cf.status,
162                symbols: Vec::new(),
163            });
164            continue;
165        }
166
167        // Parse symbols from the changed file
168        let symbols = match get_symbols_overview(project, &cf.file, 2) {
169            Ok(syms) => syms
170                .into_iter()
171                .filter(|s| !matches!(s.kind, SymbolKind::File | SymbolKind::Variable))
172                .map(|s| DiffSymbolEntry {
173                    name: s.name,
174                    kind: s.kind.as_label().to_owned(),
175                    line: s.line,
176                })
177                .collect(),
178            Err(_) => Vec::new(),
179        };
180
181        result.push(DiffSymbol {
182            file: cf.file,
183            status: cf.status,
184            symbols,
185        });
186    }
187
188    Ok(result)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn parse_name_status_basic() {
197        let output = "M\tsrc/main.py\nA\tsrc/utils.py\nD\told.py\n";
198        let files = parse_name_status(output);
199        assert_eq!(files.len(), 3);
200        assert_eq!(files[0].file, "src/main.py");
201        assert_eq!(files[0].status, "M");
202        assert_eq!(files[1].status, "A");
203        assert_eq!(files[2].status, "D");
204    }
205
206    #[test]
207    fn parse_name_status_rename() {
208        let output = "R100\told_name.py\n";
209        let files = parse_name_status(output);
210        assert_eq!(files.len(), 1);
211        assert_eq!(files[0].status, "R");
212        assert_eq!(files[0].file, "old_name.py");
213    }
214
215    #[test]
216    fn parse_name_status_empty() {
217        assert!(parse_name_status("").is_empty());
218        assert!(parse_name_status("\n\n").is_empty());
219    }
220
221    fn git_init_with_file(dir: &std::path::Path, name: &str, content: &str) {
222        std::fs::write(dir.join(name), content).unwrap();
223        std::process::Command::new("git")
224            .args(["add", name])
225            .current_dir(dir)
226            .output()
227            .unwrap();
228        std::process::Command::new("git")
229            .args(["commit", "-m", "init", "--allow-empty-message"])
230            .current_dir(dir)
231            .output()
232            .unwrap();
233    }
234
235    #[test]
236    fn classify_change_kind_additive() {
237        let tmp = tempfile::tempdir().unwrap();
238        let dir = tmp.path();
239        std::process::Command::new("git")
240            .args(["init"])
241            .current_dir(dir)
242            .output()
243            .unwrap();
244        std::process::Command::new("git")
245            .args(["config", "user.email", "test@test.com"])
246            .current_dir(dir)
247            .output()
248            .unwrap();
249        std::process::Command::new("git")
250            .args(["config", "user.name", "test"])
251            .current_dir(dir)
252            .output()
253            .unwrap();
254        git_init_with_file(dir, "lib.py", "def hello(): pass\n");
255        // Append-only change → additive
256        std::fs::write(dir.join("lib.py"), "def hello(): pass\ndef world(): pass\n").unwrap();
257        let project = ProjectRoot::new(dir.to_str().unwrap()).unwrap();
258        assert_eq!(classify_change_kind(&project, "lib.py"), "additive");
259    }
260
261    #[test]
262    fn classify_change_kind_mixed() {
263        let tmp = tempfile::tempdir().unwrap();
264        let dir = tmp.path();
265        std::process::Command::new("git")
266            .args(["init"])
267            .current_dir(dir)
268            .output()
269            .unwrap();
270        std::process::Command::new("git")
271            .args(["config", "user.email", "test@test.com"])
272            .current_dir(dir)
273            .output()
274            .unwrap();
275        std::process::Command::new("git")
276            .args(["config", "user.name", "test"])
277            .current_dir(dir)
278            .output()
279            .unwrap();
280        git_init_with_file(dir, "lib.py", "def hello(): pass\n");
281        // Replace line → mixed (has deletions)
282        std::fs::write(dir.join("lib.py"), "def goodbye(): pass\n").unwrap();
283        let project = ProjectRoot::new(dir.to_str().unwrap()).unwrap();
284        assert_eq!(classify_change_kind(&project, "lib.py"), "mixed");
285    }
286
287    #[test]
288    fn classify_change_kind_untracked() {
289        let tmp = tempfile::tempdir().unwrap();
290        let dir = tmp.path();
291        std::process::Command::new("git")
292            .args(["init"])
293            .current_dir(dir)
294            .output()
295            .unwrap();
296        // Untracked file → additive
297        std::fs::write(dir.join("new.py"), "x = 1\n").unwrap();
298        let project = ProjectRoot::new(dir.to_str().unwrap()).unwrap();
299        assert_eq!(classify_change_kind(&project, "new.py"), "additive");
300    }
301
302    #[test]
303    fn dedup_files_removes_duplicates() {
304        let files = vec![
305            ChangedFile {
306                file: "a.py".into(),
307                status: "M".into(),
308            },
309            ChangedFile {
310                file: "b.py".into(),
311                status: "A".into(),
312            },
313            ChangedFile {
314                file: "a.py".into(),
315                status: "D".into(),
316            },
317        ];
318        let deduped = dedup_files(files);
319        assert_eq!(deduped.len(), 2);
320        assert_eq!(deduped[0].file, "a.py");
321        assert_eq!(deduped[0].status, "M"); // first occurrence kept
322        assert_eq!(deduped[1].file, "b.py");
323    }
324}