1use std::path::Path;
2
3use anyhow::{Context, Result, anyhow};
4use git2::{Commit, DiffFormat, DiffOptions, Repository};
5use tracing::{debug, info};
6
7use crate::domain::config::AppConfig;
8use crate::domain::diff::{DiffDocument, DiffFile, DiffHunk, DiffLine, DiffLineKind};
9
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub enum DiffSource {
12 #[default]
13 WorkingTree,
14 Commit {
15 rev: String,
16 },
17 Range {
18 base: String,
19 head: String,
20 },
21}
22
23impl DiffSource {
24 pub fn working_tree() -> Self {
25 Self::WorkingTree
26 }
27}
28
29pub async fn load_git_diff(config: &AppConfig, source: &DiffSource) -> Result<DiffDocument> {
30 debug!(?source, "loading git diff");
31 let config = config.clone();
32 let source = source.clone();
33 let source_for_worker = source.clone();
34 let document =
35 tokio::task::spawn_blocking(move || load_git_diff_sync(config, source_for_worker))
36 .await
37 .context("failed to join git2 diff worker")??;
38 info!(files = document.files.len(), ?source, "git diff loaded");
39 Ok(document)
40}
41
42pub async fn load_git_diff_head(config: &AppConfig) -> Result<DiffDocument> {
43 load_git_diff(config, &DiffSource::WorkingTree).await
44}
45
46fn load_git_diff_sync(config: AppConfig, source: DiffSource) -> Result<DiffDocument> {
47 let repo = Repository::discover(".").context("failed to discover git repository")?;
48 load_git_diff_for_repo(&repo, &config, &source)
49}
50
51fn load_git_diff_for_repo(
52 repo: &Repository,
53 config: &AppConfig,
54 source: &DiffSource,
55) -> Result<DiffDocument> {
56 let text = load_diff_text(repo, source)?;
57 let mut document = parse_unified_diff(&text)?;
58 let ignore_repo = matches!(source, DiffSource::WorkingTree).then_some(repo);
59 filter_ignored_files(&mut document, config, ignore_repo)?;
60 Ok(document)
61}
62
63fn load_diff_text(repo: &Repository, source: &DiffSource) -> Result<String> {
64 let mut diff_opts = DiffOptions::new();
65 diff_opts.context_lines(3).include_typechange(true);
66
67 let diff = match source {
68 DiffSource::WorkingTree => {
69 configure_worktree_diff_options(&mut diff_opts);
70 let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
71 repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))
72 .context("failed to compute repository diff")?
73 }
74 DiffSource::Commit { rev } => {
75 let commit = resolve_commit(repo, rev)?;
76 let new_tree = commit.tree().context("failed to read commit tree")?;
77 let old_tree = commit
78 .parent(0)
79 .ok()
80 .map(|parent| parent.tree().context("failed to read parent tree"))
81 .transpose()?;
82 repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut diff_opts))
83 .with_context(|| format!("failed to diff commit {rev}"))?
84 }
85 DiffSource::Range { base, head } => {
86 let base_tree = resolve_commit(repo, base)?
87 .tree()
88 .with_context(|| format!("failed to read base tree for {base}"))?;
89 let head_tree = resolve_commit(repo, head)?
90 .tree()
91 .with_context(|| format!("failed to read head tree for {head}"))?;
92 repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut diff_opts))
93 .with_context(|| format!("failed to diff range {base}..{head}"))?
94 }
95 };
96
97 render_diff_text(diff)
98}
99
100fn configure_worktree_diff_options(diff_opts: &mut DiffOptions) {
101 diff_opts
102 .include_untracked(true)
103 .recurse_untracked_dirs(true)
104 .show_untracked_content(true);
105}
106
107fn render_diff_text(diff: git2::Diff<'_>) -> Result<String> {
108 let mut patch_bytes = Vec::new();
109 diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
110 match line.origin() {
111 '+' | '-' | ' ' => patch_bytes.push(line.origin() as u8),
112 _ => {}
113 }
114 patch_bytes.extend_from_slice(line.content());
115 true
116 })
117 .context("failed to render patch text")?;
118
119 String::from_utf8(patch_bytes).context("git2 patch output is not utf-8")
120}
121
122fn resolve_commit<'repo>(repo: &'repo Repository, rev: &str) -> Result<Commit<'repo>> {
123 repo.revparse_single(rev)
124 .with_context(|| format!("failed to resolve revision {rev}"))?
125 .peel_to_commit()
126 .with_context(|| format!("revision {rev} does not resolve to a commit"))
127}
128
129pub fn parse_unified_diff(text: &str) -> Result<DiffDocument> {
130 let mut files = Vec::new();
131
132 let mut current_file: Option<DiffFile> = None;
133 let mut current_hunk: Option<DiffHunk> = None;
134 let mut old_cursor: u32 = 0;
135 let mut new_cursor: u32 = 0;
136
137 for line in text.lines() {
138 if line.starts_with("diff --git ") {
139 if let Some(hunk) = current_hunk.take()
140 && let Some(file) = current_file.as_mut()
141 {
142 file.hunks.push(hunk);
143 }
144 if let Some(file) = current_file.take() {
145 files.push(file);
146 }
147 current_file = Some(DiffFile {
148 path: parse_diff_git_path(line).unwrap_or_default(),
149 header_lines: vec![line.to_string()],
150 hunks: Vec::new(),
151 });
152 continue;
153 }
154
155 if line.starts_with("@@") {
156 if current_file.is_none() {
157 current_file = Some(DiffFile {
158 path: String::new(),
159 header_lines: Vec::new(),
160 hunks: Vec::new(),
161 });
162 }
163
164 if let Some(hunk) = current_hunk.take()
165 && let Some(file) = current_file.as_mut()
166 {
167 file.hunks.push(hunk);
168 }
169
170 let (old_start, old_count, new_start, new_count) = parse_hunk_header(line)?;
171 old_cursor = old_start;
172 new_cursor = new_start;
173
174 let mut hunk = DiffHunk {
175 old_start,
176 old_count,
177 new_start,
178 new_count,
179 header: line.to_string(),
180 lines: Vec::new(),
181 };
182 hunk.lines.push(DiffLine {
183 kind: DiffLineKind::HunkHeader,
184 old_line: None,
185 new_line: None,
186 raw: line.to_string(),
187 code: line.to_string(),
188 });
189 current_hunk = Some(hunk);
190 continue;
191 }
192
193 if let Some(file) = current_file.as_mut()
194 && current_hunk.is_none()
195 {
196 if line.starts_with("+++ ") {
197 if let Some(path) = parse_patch_path(line, "+++ ") {
198 file.path = path;
199 }
200 file.header_lines.push(line.to_string());
201 continue;
202 }
203
204 if line.starts_with("--- ") {
205 if file.path.is_empty()
206 && let Some(path) = parse_patch_path(line, "--- ")
207 {
208 file.path = path;
209 }
210 file.header_lines.push(line.to_string());
211 continue;
212 }
213
214 file.header_lines.push(line.to_string());
215 continue;
216 }
217
218 if let Some(hunk) = current_hunk.as_mut() {
219 let parsed = if let Some(code) = line.strip_prefix('+') {
220 let line_value = DiffLine {
221 kind: DiffLineKind::Added,
222 old_line: None,
223 new_line: Some(new_cursor),
224 raw: line.to_string(),
225 code: code.to_string(),
226 };
227 new_cursor += 1;
228 line_value
229 } else if let Some(code) = line.strip_prefix('-') {
230 let line_value = DiffLine {
231 kind: DiffLineKind::Removed,
232 old_line: Some(old_cursor),
233 new_line: None,
234 raw: line.to_string(),
235 code: code.to_string(),
236 };
237 old_cursor += 1;
238 line_value
239 } else if let Some(code) = line.strip_prefix(' ') {
240 let line_value = DiffLine {
241 kind: DiffLineKind::Context,
242 old_line: Some(old_cursor),
243 new_line: Some(new_cursor),
244 raw: line.to_string(),
245 code: code.to_string(),
246 };
247 old_cursor += 1;
248 new_cursor += 1;
249 line_value
250 } else {
251 DiffLine {
252 kind: DiffLineKind::Meta,
253 old_line: None,
254 new_line: None,
255 raw: line.to_string(),
256 code: line.to_string(),
257 }
258 };
259
260 hunk.lines.push(parsed);
261 }
262 }
263
264 if let Some(hunk) = current_hunk.take()
265 && let Some(file) = current_file.as_mut()
266 {
267 file.hunks.push(hunk);
268 }
269
270 if let Some(file) = current_file.take() {
271 files.push(file);
272 }
273
274 Ok(DiffDocument { files })
275}
276
277fn filter_ignored_files(
278 document: &mut DiffDocument,
279 config: &AppConfig,
280 repo: Option<&Repository>,
281) -> Result<()> {
282 if !config.ignore_parley_dir && repo.is_none() {
283 return Ok(());
284 }
285
286 let mut retained = Vec::with_capacity(document.files.len());
287 for file in document.files.drain(..) {
288 if should_ignore_file(&file.path, config, repo)? {
289 continue;
290 }
291 retained.push(file);
292 }
293 document.files = retained;
294 Ok(())
295}
296
297fn is_parley_internal_path(path: &str) -> bool {
298 path == ".parley" || path.starts_with(".parley/")
299}
300
301fn should_ignore_file(path: &str, config: &AppConfig, repo: Option<&Repository>) -> Result<bool> {
302 if config.ignore_parley_dir && is_parley_internal_path(path) {
303 return Ok(true);
304 }
305
306 let Some(repo) = repo else {
307 return Ok(false);
308 };
309 repo.status_should_ignore(Path::new(path))
310 .with_context(|| format!("failed to evaluate gitignore rules for {path}"))
311}
312
313fn parse_hunk_header(line: &str) -> Result<(u32, u32, u32, u32)> {
314 let Some(rest) = line.strip_prefix("@@ -") else {
315 return Err(anyhow!("invalid hunk header format: {line}"));
316 };
317 let Some((left, right_tail)) = rest.split_once(" +") else {
318 return Err(anyhow!("invalid hunk header body: {line}"));
319 };
320 let Some((right, _tail)) = right_tail.split_once(" @@") else {
321 return Err(anyhow!("invalid hunk header end: {line}"));
322 };
323
324 let (old_start, old_count) = parse_range(left)?;
325 let (new_start, new_count) = parse_range(right)?;
326 Ok((old_start, old_count, new_start, new_count))
327}
328
329fn parse_range(value: &str) -> Result<(u32, u32)> {
330 if let Some((start, count)) = value.split_once(',') {
331 Ok((start.parse()?, count.parse()?))
332 } else {
333 Ok((value.parse()?, 1))
334 }
335}
336
337fn parse_patch_path(line: &str, marker: &str) -> Option<String> {
338 let raw = line.strip_prefix(marker)?.trim();
339 parse_diff_path(raw)
340}
341
342fn parse_diff_git_path(line: &str) -> Option<String> {
343 let raw = line.strip_prefix("diff --git ")?;
344 let (_, right) = split_diff_paths(raw)?;
345 parse_diff_path(right)
346}
347
348fn split_diff_paths(raw: &str) -> Option<(&str, &str)> {
349 let raw = raw.trim();
350 if raw.is_empty() {
351 return None;
352 }
353
354 if let Some(rest) = raw.strip_prefix('"') {
355 let end_left = rest.find('"')?;
356 let left = &raw[..=end_left + 1];
357 let rest = rest[end_left + 1..].trim_start();
358 let rest = rest.strip_prefix('"')?;
359 let end_right = rest.find('"')?;
360 let right = &rest[..=end_right];
361 return Some((left, right));
362 }
363
364 let (left, right) = raw.split_once(' ')?;
365 Some((left, right.trim_start()))
366}
367
368fn parse_diff_path(raw: &str) -> Option<String> {
369 let raw = raw.trim();
370 if raw == "/dev/null" {
371 return None;
372 }
373
374 let unquoted = raw
375 .strip_prefix('"')
376 .and_then(|v| v.strip_suffix('"'))
377 .unwrap_or(raw);
378 let normalized = unquoted
379 .strip_prefix("a/")
380 .or_else(|| unquoted.strip_prefix("b/"))
381 .unwrap_or(unquoted);
382 Some(normalized.to_string())
383}
384
385#[cfg(test)]
386mod tests {
387 use std::fs;
388
389 use git2::{Oid, Repository, Signature};
390 use tempfile::tempdir;
391
392 use crate::domain::{config::AppConfig, diff::DiffLineKind};
393
394 use super::{DiffSource, filter_ignored_files, load_git_diff_for_repo, parse_unified_diff};
395
396 #[test]
397 fn parse_unified_diff_should_parse_added_and_removed_lines_with_numbers() {
398 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";
399
400 let doc = parse_unified_diff(input).expect("diff should parse");
401
402 assert_eq!(doc.files.len(), 1);
403 assert_eq!(doc.files[0].path, "src/lib.rs");
404 assert!(
405 doc.files[0]
406 .header_lines
407 .iter()
408 .any(|line| line.starts_with("index "))
409 );
410 assert_eq!(doc.files[0].hunks.len(), 1);
411 let hunk = &doc.files[0].hunks[0];
412 assert_eq!(hunk.lines[0].kind, DiffLineKind::HunkHeader);
413 assert_eq!(hunk.lines[2].kind, DiffLineKind::Removed);
414 assert_eq!(hunk.lines[2].old_line, Some(2));
415 assert_eq!(hunk.lines[2].new_line, None);
416 assert_eq!(hunk.lines[3].kind, DiffLineKind::Added);
417 assert_eq!(hunk.lines[3].old_line, None);
418 assert_eq!(hunk.lines[3].new_line, Some(2));
419 }
420
421 #[test]
422 fn parse_unified_diff_should_use_old_path_for_deleted_files() {
423 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";
424
425 let doc = parse_unified_diff(input).expect("diff should parse");
426
427 assert_eq!(doc.files.len(), 1);
428 assert_eq!(doc.files[0].path, "src/old.rs");
429 }
430
431 #[test]
432 fn parse_unified_diff_should_parse_quoted_paths() {
433 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";
434
435 let doc = parse_unified_diff(input).expect("diff should parse");
436
437 assert_eq!(doc.files.len(), 1);
438 assert_eq!(doc.files[0].path, "src/with space.rs");
439 }
440
441 #[test]
442 fn parse_unified_diff_should_use_diff_header_path_for_binary_new_files() {
443 let input = "diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png\nnew file mode 100644\nindex 0000000..6be5e50\nBinary files /dev/null and b/src-tauri/icons/128x128.png differ\n";
444
445 let doc = parse_unified_diff(input).expect("diff should parse");
446
447 assert_eq!(doc.files.len(), 1);
448 assert_eq!(doc.files[0].path, "src-tauri/icons/128x128.png");
449 assert!(doc.files[0].hunks.is_empty());
450 }
451
452 #[test]
453 fn filter_ignored_files_removes_parley_entries_by_default() {
454 let input = "diff --git a/.parley/config.toml b/.parley/config.toml\n--- a/.parley/config.toml\n+++ b/.parley/config.toml\n@@ -1 +1 @@\n-old\n+new\ndiff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n";
455 let mut doc = parse_unified_diff(input).expect("diff should parse");
456
457 filter_ignored_files(&mut doc, &AppConfig::default(), None).expect("filter should work");
458
459 assert_eq!(doc.files.len(), 1);
460 assert_eq!(doc.files[0].path, "src/lib.rs");
461 }
462
463 #[test]
464 fn filter_ignored_files_can_keep_parley_entries_when_configured() {
465 let input = "diff --git a/.parley/config.toml b/.parley/config.toml\n--- a/.parley/config.toml\n+++ b/.parley/config.toml\n@@ -1 +1 @@\n-old\n+new\n";
466 let mut doc = parse_unified_diff(input).expect("diff should parse");
467 let config = AppConfig {
468 ignore_parley_dir: false,
469 ..AppConfig::default()
470 };
471
472 filter_ignored_files(&mut doc, &config, None).expect("filter should work");
473
474 assert_eq!(doc.files.len(), 1);
475 assert_eq!(doc.files[0].path, ".parley/config.toml");
476 }
477
478 #[test]
479 fn filter_ignored_files_removes_gitignored_paths() {
480 let temp = tempdir().expect("tempdir should exist");
481 let repo = Repository::init(temp.path()).expect("repo should init");
482 fs::write(
483 temp.path().join(".gitignore"),
484 "ignored.txt\nignored-dir/\n",
485 )
486 .expect("gitignore should write");
487 fs::write(temp.path().join("ignored.txt"), "ignored\n").expect("ignored file should write");
488 fs::create_dir_all(temp.path().join("ignored-dir")).expect("ignored dir should create");
489 fs::write(temp.path().join("ignored-dir/file.txt"), "ignored\n")
490 .expect("ignored nested file should write");
491 fs::write(temp.path().join("tracked.txt"), "tracked\n").expect("tracked file should write");
492
493 let input = "diff --git a/ignored.txt b/ignored.txt\nnew file mode 100644\nindex 0000000..1111111\nBinary files /dev/null and b/ignored.txt differ\ndiff --git a/ignored-dir/file.txt b/ignored-dir/file.txt\nnew file mode 100644\nindex 0000000..2222222\nBinary files /dev/null and b/ignored-dir/file.txt differ\ndiff --git a/tracked.txt b/tracked.txt\nnew file mode 100644\nindex 0000000..3333333\nBinary files /dev/null and b/tracked.txt differ\n";
494 let mut doc = parse_unified_diff(input).expect("diff should parse");
495
496 filter_ignored_files(&mut doc, &AppConfig::default(), Some(&repo))
497 .expect("filter should work");
498
499 assert_eq!(doc.files.len(), 1);
500 assert_eq!(doc.files[0].path, "tracked.txt");
501 }
502
503 #[test]
504 fn load_git_diff_for_commit_uses_first_parent_diff() {
505 let temp = tempdir().expect("tempdir should exist");
506 let repo = Repository::init(temp.path()).expect("repo should init");
507
508 let first = commit_file(&repo, temp.path(), "src/lib.rs", "fn first() {}\n", "first");
509 let second = commit_file(
510 &repo,
511 temp.path(),
512 "src/lib.rs",
513 "fn second() {}\n",
514 "second",
515 );
516
517 let doc = load_git_diff_for_repo(
518 &repo,
519 &AppConfig::default(),
520 &DiffSource::Commit {
521 rev: second.to_string(),
522 },
523 )
524 .expect("commit diff should load");
525
526 assert_eq!(doc.files.len(), 1);
527 assert_eq!(doc.files[0].path, "src/lib.rs");
528 let lines = &doc.files[0].hunks[0].lines;
529 assert!(lines.iter().any(|line| line.raw == "-fn first() {}"));
530 assert!(lines.iter().any(|line| line.raw == "+fn second() {}"));
531
532 let root_doc = load_git_diff_for_repo(
533 &repo,
534 &AppConfig::default(),
535 &DiffSource::Commit {
536 rev: first.to_string(),
537 },
538 )
539 .expect("root commit diff should load");
540
541 assert_eq!(root_doc.files.len(), 1);
542 assert!(
543 root_doc.files[0]
544 .hunks
545 .iter()
546 .flat_map(|hunk| hunk.lines.iter())
547 .any(|line| line.raw == "+fn first() {}")
548 );
549 }
550
551 #[test]
552 fn load_git_diff_for_range_uses_explicit_base_and_head() {
553 let temp = tempdir().expect("tempdir should exist");
554 let repo = Repository::init(temp.path()).expect("repo should init");
555
556 let base = commit_file(&repo, temp.path(), "src/lib.rs", "fn one() {}\n", "one");
557 let _middle = commit_file(&repo, temp.path(), "src/lib.rs", "fn two() {}\n", "two");
558 let head = commit_file(&repo, temp.path(), "src/lib.rs", "fn three() {}\n", "three");
559
560 let doc = load_git_diff_for_repo(
561 &repo,
562 &AppConfig::default(),
563 &DiffSource::Range {
564 base: base.to_string(),
565 head: head.to_string(),
566 },
567 )
568 .expect("range diff should load");
569
570 assert_eq!(doc.files.len(), 1);
571 let lines = &doc.files[0].hunks[0].lines;
572 assert!(lines.iter().any(|line| line.raw == "-fn one() {}"));
573 assert!(lines.iter().any(|line| line.raw == "+fn three() {}"));
574 assert!(!lines.iter().any(|line| line.raw == "+fn two() {}"));
575 }
576
577 fn commit_file(
578 repo: &Repository,
579 root: &std::path::Path,
580 relative_path: &str,
581 content: &str,
582 message: &str,
583 ) -> Oid {
584 let path = root.join(relative_path);
585 if let Some(parent) = path.parent() {
586 fs::create_dir_all(parent).expect("parent directory should exist");
587 }
588 fs::write(&path, content).expect("file should write");
589
590 let mut index = repo.index().expect("index should open");
591 index
592 .add_path(std::path::Path::new(relative_path))
593 .expect("path should stage");
594 index.write().expect("index should write");
595
596 let tree_oid = index.write_tree().expect("tree should write");
597 let tree = repo.find_tree(tree_oid).expect("tree should load");
598 let signature =
599 Signature::now("Parley Test", "parley@example.com").expect("signature should create");
600 let parents = repo
601 .head()
602 .ok()
603 .and_then(|head| head.target())
604 .map(|oid| repo.find_commit(oid).expect("parent commit should load"))
605 .into_iter()
606 .collect::<Vec<_>>();
607 let parent_refs = parents.iter().collect::<Vec<_>>();
608
609 repo.commit(
610 Some("HEAD"),
611 &signature,
612 &signature,
613 message,
614 &tree,
615 &parent_refs,
616 )
617 .expect("commit should succeed")
618 }
619}