1use crate::Repository;
6use crate::error::{Error, Result};
7use git2::Oid;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Hunk {
12 pub file_path: String,
14 pub old_start: u32,
16 pub old_lines: u32,
18 pub new_start: u32,
20 pub new_lines: u32,
22 pub content: String,
24 pub is_new_file: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct BlameResult {
31 pub commit: Oid,
33 pub message: String,
35}
36
37impl Repository {
38 pub fn staged_diff_hunks(&self) -> Result<Vec<Hunk>> {
46 let workdir = self.workdir().ok_or(Error::NotARepository)?;
47
48 let output = std::process::Command::new("git")
49 .args(["diff", "--cached", "-U0", "--no-color"])
50 .current_dir(workdir)
51 .output()
52 .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
53
54 if !output.status.success() {
55 let stderr = String::from_utf8_lossy(&output.stderr);
56 return Err(Error::Git2(git2::Error::from_str(&stderr)));
57 }
58
59 let stdout = String::from_utf8_lossy(&output.stdout);
60 Ok(parse_diff_hunks(&stdout))
61 }
62
63 pub fn blame_lines(&self, file_path: &str, start: u32, end: u32) -> Result<Vec<BlameResult>> {
71 let workdir = self.workdir().ok_or(Error::NotARepository)?;
72
73 let line_range = format!("{start},{end}");
75 let output = std::process::Command::new("git")
76 .args(["blame", "-l", "-s", "-L", &line_range, "--", file_path])
77 .current_dir(workdir)
78 .output()
79 .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
80
81 if !output.status.success() {
82 let stderr = String::from_utf8_lossy(&output.stderr);
83 return Err(Error::Git2(git2::Error::from_str(&stderr)));
84 }
85
86 let stdout = String::from_utf8_lossy(&output.stdout);
87 self.parse_blame_output(&stdout)
88 }
89
90 pub fn create_fixup_commit(&self, target: Oid) -> Result<Oid> {
98 let workdir = self.workdir().ok_or(Error::NotARepository)?;
99
100 let output = std::process::Command::new("git")
101 .args(["commit", "--fixup", &target.to_string()])
102 .current_dir(workdir)
103 .output()
104 .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
105
106 if !output.status.success() {
107 let stderr = String::from_utf8_lossy(&output.stderr);
108 return Err(Error::Git2(git2::Error::from_str(&stderr)));
109 }
110
111 let head = self.inner().head()?.peel_to_commit()?;
113 Ok(head.id())
114 }
115
116 fn parse_blame_output(&self, output: &str) -> Result<Vec<BlameResult>> {
118 let mut results = Vec::new();
119 let mut seen_commits = std::collections::HashSet::new();
120
121 for line in output.lines() {
122 let (sha, is_boundary) = if line.starts_with('^') {
128 if line.len() < 41 {
130 continue;
131 }
132 (&line[1..41], true)
133 } else {
134 if line.len() < 40 {
135 continue;
136 }
137 (&line[..40], false)
138 };
139
140 if is_boundary {
142 continue;
143 }
144
145 if seen_commits.contains(sha) {
147 continue;
148 }
149 seen_commits.insert(sha.to_string());
150
151 let oid = Oid::from_str(sha)
152 .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
153
154 let commit = self.find_commit(oid)?;
156 let message = commit
157 .message()
158 .unwrap_or("")
159 .lines()
160 .next()
161 .unwrap_or("")
162 .to_string();
163
164 results.push(BlameResult {
165 commit: oid,
166 message,
167 });
168 }
169
170 Ok(results)
171 }
172
173 pub fn is_ancestor(&self, ancestor: Oid, descendant: Oid) -> Result<bool> {
180 Ok(self.inner().graph_descendant_of(descendant, ancestor)?)
181 }
182}
183
184fn parse_diff_hunks(diff: &str) -> Vec<Hunk> {
186 let mut hunks = Vec::new();
187 let mut current_file: Option<String> = None;
188 let mut hunk_content = String::new();
189 let mut current_hunk: Option<(u32, u32, u32, u32)> = None;
190 let mut is_new_file = false;
191
192 for line in diff.lines() {
193 if line.starts_with("diff --git ") {
195 if let (Some(file), Some((old_start, old_lines, new_start, new_lines))) =
197 (¤t_file, current_hunk)
198 {
199 hunks.push(Hunk {
200 file_path: file.clone(),
201 old_start,
202 old_lines,
203 new_start,
204 new_lines,
205 content: hunk_content.clone(),
206 is_new_file,
207 });
208 }
209 hunk_content.clear();
210 current_hunk = None;
211 is_new_file = false;
212
213 current_file = parse_diff_git_path(line);
216 continue;
217 }
218
219 if line.starts_with("new file mode") {
221 is_new_file = true;
222 continue;
223 }
224
225 if line.starts_with("@@ ") {
227 if let (Some(file), Some((old_start, old_lines, new_start, new_lines))) =
229 (¤t_file, current_hunk)
230 {
231 hunks.push(Hunk {
232 file_path: file.clone(),
233 old_start,
234 old_lines,
235 new_start,
236 new_lines,
237 content: hunk_content.clone(),
238 is_new_file,
239 });
240 }
241 hunk_content.clear();
242
243 if let Some((old, new)) = parse_hunk_header(line) {
245 current_hunk = Some((old.0, old.1, new.0, new.1));
246 }
247 continue;
248 }
249
250 if line.starts_with("---")
252 || line.starts_with("+++")
253 || line.starts_with("index ")
254 || line.starts_with("deleted file")
255 {
256 continue;
257 }
258
259 if current_hunk.is_some() {
261 hunk_content.push_str(line);
262 hunk_content.push('\n');
263 }
264 }
265
266 if let (Some(file), Some((old_start, old_lines, new_start, new_lines))) =
268 (¤t_file, current_hunk)
269 {
270 hunks.push(Hunk {
271 file_path: file.clone(),
272 old_start,
273 old_lines,
274 new_start,
275 new_lines,
276 content: hunk_content,
277 is_new_file,
278 });
279 }
280
281 hunks
282}
283
284fn parse_diff_git_path(line: &str) -> Option<String> {
289 let line = line.strip_prefix("diff --git ")?;
291
292 let after_a = line.strip_prefix("a/")?;
295
296 let total_len = after_a.len();
300 if total_len < 4 {
301 return None;
303 }
304
305 let path_len = (total_len - 3) / 2;
307
308 let expected_sep = &after_a[path_len..path_len + 3];
310 if expected_sep == " b/" {
311 let a_path = &after_a[..path_len];
312 let b_path = &after_a[path_len + 3..];
313
314 if a_path == b_path {
316 return Some(b_path.to_string());
317 }
318 return Some(b_path.to_string());
320 }
321
322 line.split(" b/").nth(1).map(String::from)
324}
325
326fn parse_hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
328 let line = line.trim_start_matches("@@ ").trim_end_matches(" @@");
330 let line = line.split(" @@").next()?; let parts: Vec<&str> = line.split_whitespace().collect();
333 if parts.len() < 2 {
334 return None;
335 }
336
337 let old = parse_range(parts[0].trim_start_matches('-'))?;
338 let new = parse_range(parts[1].trim_start_matches('+'))?;
339
340 Some((old, new))
341}
342
343fn parse_range(s: &str) -> Option<(u32, u32)> {
345 if let Some((start, count)) = s.split_once(',') {
346 Some((start.parse().ok()?, count.parse().ok()?))
347 } else {
348 Some((s.parse().ok()?, 1))
350 }
351}
352
353#[cfg(test)]
354#[allow(clippy::unwrap_used)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn test_parse_hunk_header() {
360 assert_eq!(parse_hunk_header("@@ -1,3 +1,4 @@"), Some(((1, 3), (1, 4))));
361 assert_eq!(
362 parse_hunk_header("@@ -10,5 +12,7 @@ fn foo()"),
363 Some(((10, 5), (12, 7)))
364 );
365 assert_eq!(parse_hunk_header("@@ -1 +1,2 @@"), Some(((1, 1), (1, 2))));
366 assert_eq!(parse_hunk_header("@@ -0,0 +1,5 @@"), Some(((0, 0), (1, 5))));
367 }
368
369 #[test]
370 fn test_parse_range() {
371 assert_eq!(parse_range("1,3"), Some((1, 3)));
372 assert_eq!(parse_range("10"), Some((10, 1)));
373 assert_eq!(parse_range("0,0"), Some((0, 0)));
374 }
375
376 #[test]
377 fn test_parse_diff_git_path() {
378 assert_eq!(
380 parse_diff_git_path("diff --git a/src/main.rs b/src/main.rs"),
381 Some("src/main.rs".to_string())
382 );
383
384 assert_eq!(
386 parse_diff_git_path("diff --git a/path with spaces/file.rs b/path with spaces/file.rs"),
387 Some("path with spaces/file.rs".to_string())
388 );
389
390 assert_eq!(
392 parse_diff_git_path("diff --git a/a b/c/file.rs b/a b/c/file.rs"),
393 Some("a b/c/file.rs".to_string())
394 );
395
396 assert_eq!(
398 parse_diff_git_path("diff --git a/deep/nested/path/file.rs b/deep/nested/path/file.rs"),
399 Some("deep/nested/path/file.rs".to_string())
400 );
401 }
402
403 #[test]
404 fn test_parse_diff_hunks_single_file() {
405 let diff = r#"diff --git a/src/main.rs b/src/main.rs
406index abc123..def456 100644
407--- a/src/main.rs
408+++ b/src/main.rs
409@@ -10,3 +10,4 @@ fn main() {
410 println!("hello");
411+ println!("world");
412 }
413"#;
414
415 let hunks = parse_diff_hunks(diff);
416 assert_eq!(hunks.len(), 1);
417 assert_eq!(hunks[0].file_path, "src/main.rs");
418 assert_eq!(hunks[0].old_start, 10);
419 assert_eq!(hunks[0].old_lines, 3);
420 assert_eq!(hunks[0].new_start, 10);
421 assert_eq!(hunks[0].new_lines, 4);
422 assert!(!hunks[0].is_new_file);
423 }
424
425 #[test]
426 fn test_parse_diff_hunks_multiple_hunks() {
427 let diff = r"diff --git a/file.txt b/file.txt
428index abc..def 100644
429--- a/file.txt
430+++ b/file.txt
431@@ -1,2 +1,3 @@
432 line1
433+added
434 line2
435@@ -10,1 +11,2 @@
436 line10
437+another
438";
439
440 let hunks = parse_diff_hunks(diff);
441 assert_eq!(hunks.len(), 2);
442 assert_eq!(hunks[0].old_start, 1);
443 assert_eq!(hunks[1].old_start, 10);
444 assert!(!hunks[0].is_new_file);
445 assert!(!hunks[1].is_new_file);
446 }
447
448 #[test]
449 fn test_parse_diff_hunks_new_file() {
450 let diff = r"diff --git a/new.txt b/new.txt
451new file mode 100644
452index 0000000..abc123
453--- /dev/null
454+++ b/new.txt
455@@ -0,0 +1,3 @@
456+line1
457+line2
458+line3
459";
460
461 let hunks = parse_diff_hunks(diff);
462 assert_eq!(hunks.len(), 1);
463 assert_eq!(hunks[0].file_path, "new.txt");
464 assert_eq!(hunks[0].old_start, 0);
465 assert_eq!(hunks[0].old_lines, 0);
466 assert_eq!(hunks[0].new_start, 1);
467 assert_eq!(hunks[0].new_lines, 3);
468 assert!(hunks[0].is_new_file);
469 }
470}