Skip to main content

parley/git/
diff.rs

1use anyhow::{Context, Result, anyhow};
2use git2::{DiffFormat, DiffOptions, Repository};
3use tracing::{debug, info};
4
5use crate::domain::diff::{DiffDocument, DiffFile, DiffHunk, DiffLine, DiffLineKind};
6
7pub async fn load_git_diff_head() -> Result<DiffDocument> {
8    debug!("loading git diff against HEAD/index/worktree");
9    let text = tokio::task::spawn_blocking(load_diff_text)
10        .await
11        .context("failed to join git2 diff worker")??;
12
13    let document = parse_unified_diff(&text)?;
14    info!(files = document.files.len(), "git diff loaded");
15    Ok(document)
16}
17
18fn load_diff_text() -> Result<String> {
19    let repo = Repository::discover(".").context("failed to discover git repository")?;
20    let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
21
22    let mut diff_opts = DiffOptions::new();
23    diff_opts
24        .context_lines(3)
25        .include_untracked(true)
26        .recurse_untracked_dirs(true)
27        .show_untracked_content(true)
28        .include_typechange(true);
29    let diff = repo
30        .diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))
31        .context("failed to compute repository diff")?;
32
33    let mut patch_bytes = Vec::new();
34    diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
35        match line.origin() {
36            '+' | '-' | ' ' => patch_bytes.push(line.origin() as u8),
37            _ => {}
38        }
39        patch_bytes.extend_from_slice(line.content());
40        true
41    })
42    .context("failed to render patch text")?;
43
44    String::from_utf8(patch_bytes).context("git2 patch output is not utf-8")
45}
46
47pub fn parse_unified_diff(text: &str) -> Result<DiffDocument> {
48    let mut files = Vec::new();
49
50    let mut current_file: Option<DiffFile> = None;
51    let mut current_hunk: Option<DiffHunk> = None;
52    let mut old_cursor: u32 = 0;
53    let mut new_cursor: u32 = 0;
54
55    for line in text.lines() {
56        if line.starts_with("diff --git ") {
57            if let Some(hunk) = current_hunk.take()
58                && let Some(file) = current_file.as_mut()
59            {
60                file.hunks.push(hunk);
61            }
62            if let Some(file) = current_file.take() {
63                files.push(file);
64            }
65            current_file = Some(DiffFile {
66                path: String::new(),
67                header_lines: vec![line.to_string()],
68                hunks: Vec::new(),
69            });
70            continue;
71        }
72
73        if line.starts_with("@@") {
74            if current_file.is_none() {
75                current_file = Some(DiffFile {
76                    path: String::new(),
77                    header_lines: Vec::new(),
78                    hunks: Vec::new(),
79                });
80            }
81
82            if let Some(hunk) = current_hunk.take()
83                && let Some(file) = current_file.as_mut()
84            {
85                file.hunks.push(hunk);
86            }
87
88            let (old_start, old_count, new_start, new_count) = parse_hunk_header(line)?;
89            old_cursor = old_start;
90            new_cursor = new_start;
91
92            let mut hunk = DiffHunk {
93                old_start,
94                old_count,
95                new_start,
96                new_count,
97                header: line.to_string(),
98                lines: Vec::new(),
99            };
100            hunk.lines.push(DiffLine {
101                kind: DiffLineKind::HunkHeader,
102                old_line: None,
103                new_line: None,
104                raw: line.to_string(),
105                code: line.to_string(),
106            });
107            current_hunk = Some(hunk);
108            continue;
109        }
110
111        if let Some(file) = current_file.as_mut()
112            && current_hunk.is_none()
113        {
114            if line.starts_with("+++ ") {
115                if let Some(path) = parse_patch_path(line, "+++ ") {
116                    file.path = path;
117                }
118                file.header_lines.push(line.to_string());
119                continue;
120            }
121
122            if line.starts_with("--- ") {
123                if file.path.is_empty()
124                    && let Some(path) = parse_patch_path(line, "--- ")
125                {
126                    file.path = path;
127                }
128                file.header_lines.push(line.to_string());
129                continue;
130            }
131
132            file.header_lines.push(line.to_string());
133            continue;
134        }
135
136        if let Some(hunk) = current_hunk.as_mut() {
137            let parsed = if let Some(code) = line.strip_prefix('+') {
138                let line_value = DiffLine {
139                    kind: DiffLineKind::Added,
140                    old_line: None,
141                    new_line: Some(new_cursor),
142                    raw: line.to_string(),
143                    code: code.to_string(),
144                };
145                new_cursor += 1;
146                line_value
147            } else if let Some(code) = line.strip_prefix('-') {
148                let line_value = DiffLine {
149                    kind: DiffLineKind::Removed,
150                    old_line: Some(old_cursor),
151                    new_line: None,
152                    raw: line.to_string(),
153                    code: code.to_string(),
154                };
155                old_cursor += 1;
156                line_value
157            } else if let Some(code) = line.strip_prefix(' ') {
158                let line_value = DiffLine {
159                    kind: DiffLineKind::Context,
160                    old_line: Some(old_cursor),
161                    new_line: Some(new_cursor),
162                    raw: line.to_string(),
163                    code: code.to_string(),
164                };
165                old_cursor += 1;
166                new_cursor += 1;
167                line_value
168            } else {
169                DiffLine {
170                    kind: DiffLineKind::Meta,
171                    old_line: None,
172                    new_line: None,
173                    raw: line.to_string(),
174                    code: line.to_string(),
175                }
176            };
177
178            hunk.lines.push(parsed);
179        }
180    }
181
182    if let Some(hunk) = current_hunk.take()
183        && let Some(file) = current_file.as_mut()
184    {
185        file.hunks.push(hunk);
186    }
187
188    if let Some(file) = current_file.take() {
189        files.push(file);
190    }
191
192    Ok(DiffDocument { files })
193}
194
195fn parse_hunk_header(line: &str) -> Result<(u32, u32, u32, u32)> {
196    let Some(rest) = line.strip_prefix("@@ -") else {
197        return Err(anyhow!("invalid hunk header format: {line}"));
198    };
199    let Some((left, right_tail)) = rest.split_once(" +") else {
200        return Err(anyhow!("invalid hunk header body: {line}"));
201    };
202    let Some((right, _tail)) = right_tail.split_once(" @@") else {
203        return Err(anyhow!("invalid hunk header end: {line}"));
204    };
205
206    let (old_start, old_count) = parse_range(left)?;
207    let (new_start, new_count) = parse_range(right)?;
208    Ok((old_start, old_count, new_start, new_count))
209}
210
211fn parse_range(value: &str) -> Result<(u32, u32)> {
212    if let Some((start, count)) = value.split_once(',') {
213        Ok((start.parse()?, count.parse()?))
214    } else {
215        Ok((value.parse()?, 1))
216    }
217}
218
219fn parse_patch_path(line: &str, marker: &str) -> Option<String> {
220    let raw = line.strip_prefix(marker)?.trim();
221    if raw == "/dev/null" {
222        return None;
223    }
224
225    let unquoted = raw
226        .strip_prefix('"')
227        .and_then(|v| v.strip_suffix('"'))
228        .unwrap_or(raw);
229    let normalized = unquoted
230        .strip_prefix("a/")
231        .or_else(|| unquoted.strip_prefix("b/"))
232        .unwrap_or(unquoted);
233    Some(normalized.to_string())
234}
235
236#[cfg(test)]
237mod tests {
238    use crate::domain::diff::DiffLineKind;
239
240    use super::parse_unified_diff;
241
242    #[test]
243    fn parse_unified_diff_should_parse_added_and_removed_lines_with_numbers() {
244        let input = "diff --git a/src/lib.rs b/src/lib.rs\nindex 123..456 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,2 +1,3 @@\n fn a() {}\n-fn b() {}\n+fn b() {\"x\";}\n+fn c() {}\n";
245
246        let doc = parse_unified_diff(input).expect("diff should parse");
247
248        assert_eq!(doc.files.len(), 1);
249        assert_eq!(doc.files[0].path, "src/lib.rs");
250        assert!(
251            doc.files[0]
252                .header_lines
253                .iter()
254                .any(|line| line.starts_with("index "))
255        );
256        assert_eq!(doc.files[0].hunks.len(), 1);
257        let hunk = &doc.files[0].hunks[0];
258        assert_eq!(hunk.lines[0].kind, DiffLineKind::HunkHeader);
259        assert_eq!(hunk.lines[2].kind, DiffLineKind::Removed);
260        assert_eq!(hunk.lines[2].old_line, Some(2));
261        assert_eq!(hunk.lines[2].new_line, None);
262        assert_eq!(hunk.lines[3].kind, DiffLineKind::Added);
263        assert_eq!(hunk.lines[3].old_line, None);
264        assert_eq!(hunk.lines[3].new_line, Some(2));
265    }
266
267    #[test]
268    fn parse_unified_diff_should_use_old_path_for_deleted_files() {
269        let input = "diff --git a/src/old.rs b/src/old.rs\nindex 123..456 100644\n--- a/src/old.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-fn old() {}\n";
270
271        let doc = parse_unified_diff(input).expect("diff should parse");
272
273        assert_eq!(doc.files.len(), 1);
274        assert_eq!(doc.files[0].path, "src/old.rs");
275    }
276
277    #[test]
278    fn parse_unified_diff_should_parse_quoted_paths() {
279        let input = "diff --git \"a/src/with space.rs\" \"b/src/with space.rs\"\nindex 123..456 100644\n--- \"a/src/with space.rs\"\n+++ \"b/src/with space.rs\"\n@@ -1 +1 @@\n-fn before() {}\n+fn after() {}\n";
280
281        let doc = parse_unified_diff(input).expect("diff should parse");
282
283        assert_eq!(doc.files.len(), 1);
284        assert_eq!(doc.files[0].path, "src/with space.rs");
285    }
286}