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}