Skip to main content

rung_git/
absorb.rs

1//! Git operations for the absorb command.
2//!
3//! Provides diff parsing, blame queries, and fixup commit creation.
4
5use crate::Repository;
6use crate::error::{Error, Result};
7use crate::traits::AbsorbOps;
8use git2::Oid;
9
10/// A hunk of changes from a staged diff.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Hunk {
13    /// File path relative to repository root.
14    pub file_path: String,
15    /// Starting line in the original file (1-indexed, before changes).
16    pub old_start: u32,
17    /// Number of lines in the original file.
18    pub old_lines: u32,
19    /// Starting line in the new file (1-indexed, after changes).
20    pub new_start: u32,
21    /// Number of lines in the new file.
22    pub new_lines: u32,
23    /// The actual diff content (context + changes).
24    pub content: String,
25    /// Whether this hunk is from a newly created file.
26    pub is_new_file: bool,
27}
28
29/// Result of a blame query for a line range.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct BlameResult {
32    /// The commit that last modified this line range.
33    pub commit: Oid,
34    /// Short commit message (first line).
35    pub message: String,
36}
37
38impl Repository {
39    /// Get the staged diff as a list of hunks.
40    ///
41    /// Parses `git diff --cached` output to extract individual hunks
42    /// with file paths and line ranges.
43    ///
44    /// # Errors
45    /// Returns error if git diff fails or output cannot be parsed.
46    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    /// Query git blame for a specific line range in a file.
65    ///
66    /// Returns the commits that last modified lines in the given range.
67    /// Uses `git blame -L <start>,<end>` for targeted queries.
68    ///
69    /// # Errors
70    /// Returns error if blame fails or commit cannot be found.
71    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        // Use -l for full commit hashes, -s for suppressing author/date
75        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    /// Create a fixup commit targeting the specified commit.
92    ///
93    /// Equivalent to `git commit --fixup=<target>`.
94    /// Staged changes must exist before calling this.
95    ///
96    /// # Errors
97    /// Returns error if commit creation fails.
98    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        // Get the commit we just created
113        let head = self.inner().head()?.peel_to_commit()?;
114        Ok(head.id())
115    }
116
117    /// Parse blame output into `BlameResult` items.
118    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            // Format: <40-char-sha> <line-num> <content>
124            // Boundary commits start with '^': ^<39-char-sha> <line-num> <content>
125            // With -s flag, no author/date info
126
127            // Detect and skip boundary commits (start with ^) first
128            let (sha, is_boundary) = if line.starts_with('^') {
129                // Boundary commit: ^<39-char SHA> (40 chars total including ^)
130                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            // Skip boundary commits - they're outside the blame range
142            if is_boundary {
143                continue;
144            }
145
146            // Skip if we've already seen this commit
147            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            // Get commit message
156            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    /// Check if a commit is an ancestor of another commit.
175    ///
176    /// Returns true if `ancestor` is reachable from `descendant`.
177    ///
178    /// # Errors
179    /// Returns error if the check fails.
180    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
203/// Parse unified diff output into hunks.
204fn 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        // New file header: diff --git a/path b/path
213        if line.starts_with("diff --git ") {
214            // Save previous hunk if exists
215            if let (Some(file), Some((old_start, old_lines, new_start, new_lines))) =
216                (&current_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            // Parse file path from "diff --git a/path b/path"
233            // Use robust parsing to handle paths containing " b/"
234            current_file = parse_diff_git_path(line);
235            continue;
236        }
237
238        // Detect new file mode
239        if line.starts_with("new file mode") {
240            is_new_file = true;
241            continue;
242        }
243
244        // Hunk header: @@ -old_start,old_lines +new_start,new_lines @@
245        if line.starts_with("@@ ") {
246            // Save previous hunk if exists
247            if let (Some(file), Some((old_start, old_lines, new_start, new_lines))) =
248                (&current_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            // Parse hunk header
263            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        // Skip other headers
270        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        // Accumulate hunk content
279        if current_hunk.is_some() {
280            hunk_content.push_str(line);
281            hunk_content.push('\n');
282        }
283    }
284
285    // Don't forget the last hunk
286    if let (Some(file), Some((old_start, old_lines, new_start, new_lines))) =
287        (&current_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
303/// Parse file path from a "diff --git a/path b/path" line.
304///
305/// Handles edge cases like paths containing " b/" by using the fact that
306/// git's diff output format has symmetric a-path and b-path (except for renames).
307fn parse_diff_git_path(line: &str) -> Option<String> {
308    // Format: "diff --git a/<path> b/<path>"
309    let line = line.strip_prefix("diff --git ")?;
310
311    // For non-rename cases, a-path and b-path are identical.
312    // The line format is "a/<path> b/<path>".
313    let after_a = line.strip_prefix("a/")?;
314
315    // Since paths are symmetric: "<path> b/<path>"
316    // Total length = path_len + 3 (" b/") + path_len
317    // So: path_len = (total_len - 3) / 2
318    let total_len = after_a.len();
319    if total_len < 4 {
320        // Too short to contain " b/" + at least 1 char
321        return None;
322    }
323
324    // Calculate expected path length assuming symmetric paths
325    let path_len = (total_len - 3) / 2;
326
327    // Verify the separator is at the expected position
328    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        // Verify paths match (for non-renames)
334        if a_path == b_path {
335            return Some(b_path.to_string());
336        }
337        // For renames, paths differ; return b-path (destination)
338        return Some(b_path.to_string());
339    }
340
341    // Fallback for unusual cases: try simple split
342    line.split(" b/").nth(1).map(String::from)
343}
344
345/// Parse a hunk header line like "@@ -1,3 +1,4 @@" or "@@ -1 +1,2 @@"
346fn parse_hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
347    // Strip @@ markers
348    let line = line.trim_start_matches("@@ ").trim_end_matches(" @@");
349    let line = line.split(" @@").next()?; // Handle trailing context
350
351    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
362/// Parse a range like "1,3" or "1" into (start, count).
363fn 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        // Single line: "1" means start=1, count=1
368        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        // Simple path
398        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        // Path with spaces
404        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        // Path containing " b/" substring (edge case)
410        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        // Nested directories
416        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}