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