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 git2::Oid;
8
9/// A hunk of changes from a staged diff.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Hunk {
12    /// File path relative to repository root.
13    pub file_path: String,
14    /// Starting line in the original file (1-indexed, before changes).
15    pub old_start: u32,
16    /// Number of lines in the original file.
17    pub old_lines: u32,
18    /// Starting line in the new file (1-indexed, after changes).
19    pub new_start: u32,
20    /// Number of lines in the new file.
21    pub new_lines: u32,
22    /// The actual diff content (context + changes).
23    pub content: String,
24    /// Whether this hunk is from a newly created file.
25    pub is_new_file: bool,
26}
27
28/// Result of a blame query for a line range.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct BlameResult {
31    /// The commit that last modified this line range.
32    pub commit: Oid,
33    /// Short commit message (first line).
34    pub message: String,
35}
36
37impl Repository {
38    /// Get the staged diff as a list of hunks.
39    ///
40    /// Parses `git diff --cached` output to extract individual hunks
41    /// with file paths and line ranges.
42    ///
43    /// # Errors
44    /// Returns error if git diff fails or output cannot be parsed.
45    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    /// Query git blame for a specific line range in a file.
64    ///
65    /// Returns the commits that last modified lines in the given range.
66    /// Uses `git blame -L <start>,<end>` for targeted queries.
67    ///
68    /// # Errors
69    /// Returns error if blame fails or commit cannot be found.
70    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        // Use -l for full commit hashes, -s for suppressing author/date
74        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    /// Create a fixup commit targeting the specified commit.
91    ///
92    /// Equivalent to `git commit --fixup=<target>`.
93    /// Staged changes must exist before calling this.
94    ///
95    /// # Errors
96    /// Returns error if commit creation fails.
97    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        // Get the commit we just created
112        let head = self.inner().head()?.peel_to_commit()?;
113        Ok(head.id())
114    }
115
116    /// Parse blame output into `BlameResult` items.
117    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            // Format: <40-char-sha> <line-num> <content>
123            // Boundary commits start with '^': ^<39-char-sha> <line-num> <content>
124            // With -s flag, no author/date info
125
126            // Detect and skip boundary commits (start with ^) first
127            let (sha, is_boundary) = if line.starts_with('^') {
128                // Boundary commit: ^<39-char SHA> (40 chars total including ^)
129                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            // Skip boundary commits - they're outside the blame range
141            if is_boundary {
142                continue;
143            }
144
145            // Skip if we've already seen this commit
146            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            // Get commit message
155            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    /// Check if a commit is an ancestor of another commit.
174    ///
175    /// Returns true if `ancestor` is reachable from `descendant`.
176    ///
177    /// # Errors
178    /// Returns error if the check fails.
179    pub fn is_ancestor(&self, ancestor: Oid, descendant: Oid) -> Result<bool> {
180        Ok(self.inner().graph_descendant_of(descendant, ancestor)?)
181    }
182}
183
184/// Parse unified diff output into hunks.
185fn 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        // New file header: diff --git a/path b/path
194        if line.starts_with("diff --git ") {
195            // Save previous hunk if exists
196            if let (Some(file), Some((old_start, old_lines, new_start, new_lines))) =
197                (&current_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            // Parse file path from "diff --git a/path b/path"
214            // Use robust parsing to handle paths containing " b/"
215            current_file = parse_diff_git_path(line);
216            continue;
217        }
218
219        // Detect new file mode
220        if line.starts_with("new file mode") {
221            is_new_file = true;
222            continue;
223        }
224
225        // Hunk header: @@ -old_start,old_lines +new_start,new_lines @@
226        if line.starts_with("@@ ") {
227            // Save previous hunk if exists
228            if let (Some(file), Some((old_start, old_lines, new_start, new_lines))) =
229                (&current_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            // Parse hunk header
244            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        // Skip other headers
251        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        // Accumulate hunk content
260        if current_hunk.is_some() {
261            hunk_content.push_str(line);
262            hunk_content.push('\n');
263        }
264    }
265
266    // Don't forget the last hunk
267    if let (Some(file), Some((old_start, old_lines, new_start, new_lines))) =
268        (&current_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
284/// Parse file path from a "diff --git a/path b/path" line.
285///
286/// Handles edge cases like paths containing " b/" by using the fact that
287/// git's diff output format has symmetric a-path and b-path (except for renames).
288fn parse_diff_git_path(line: &str) -> Option<String> {
289    // Format: "diff --git a/<path> b/<path>"
290    let line = line.strip_prefix("diff --git ")?;
291
292    // For non-rename cases, a-path and b-path are identical.
293    // The line format is "a/<path> b/<path>".
294    let after_a = line.strip_prefix("a/")?;
295
296    // Since paths are symmetric: "<path> b/<path>"
297    // Total length = path_len + 3 (" b/") + path_len
298    // So: path_len = (total_len - 3) / 2
299    let total_len = after_a.len();
300    if total_len < 4 {
301        // Too short to contain " b/" + at least 1 char
302        return None;
303    }
304
305    // Calculate expected path length assuming symmetric paths
306    let path_len = (total_len - 3) / 2;
307
308    // Verify the separator is at the expected position
309    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        // Verify paths match (for non-renames)
315        if a_path == b_path {
316            return Some(b_path.to_string());
317        }
318        // For renames, paths differ; return b-path (destination)
319        return Some(b_path.to_string());
320    }
321
322    // Fallback for unusual cases: try simple split
323    line.split(" b/").nth(1).map(String::from)
324}
325
326/// Parse a hunk header line like "@@ -1,3 +1,4 @@" or "@@ -1 +1,2 @@"
327fn parse_hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
328    // Strip @@ markers
329    let line = line.trim_start_matches("@@ ").trim_end_matches(" @@");
330    let line = line.split(" @@").next()?; // Handle trailing context
331
332    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
343/// Parse a range like "1,3" or "1" into (start, count).
344fn 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        // Single line: "1" means start=1, count=1
349        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        // Simple path
379        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        // Path with spaces
385        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        // Path containing " b/" substring (edge case)
391        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        // Nested directories
397        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}