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 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 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 match run_git(project, &["diff", "--name-status", ref_target]) {
84 Ok(output) => all_files.extend(parse_name_status(&output)),
85 Err(e) => {
86 let msg = e.to_string();
88 if !msg.contains("unknown revision") && !msg.contains("ambiguous argument") {
89 return Err(e);
90 }
91 }
92 }
93
94 if let Ok(output) = run_git(project, &["diff", "--name-status"]) {
96 all_files.extend(parse_name_status(&output));
97 }
98
99 if let Ok(output) = run_git(project, &["diff", "--name-status", "--cached"]) {
101 all_files.extend(parse_name_status(&output));
102 }
103
104 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
122pub fn classify_change_kind(project: &ProjectRoot, file_path: &str) -> String {
126 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 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 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 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 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 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 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"); assert_eq!(deduped[1].file, "b.py");
323 }
324}