gitkraft_core/features/diff/
ops.rs1use anyhow::{Context, Result};
4use git2::{Diff, DiffFormat, DiffOptions, Repository};
5
6use super::types::{DiffHunk, DiffInfo, DiffLine, FileStatus};
7
8pub fn get_working_dir_diff(repo: &Repository) -> Result<Vec<DiffInfo>> {
14 let mut opts = DiffOptions::new();
15 opts.include_untracked(true);
16 opts.recurse_untracked_dirs(true);
17
18 let diff = repo
19 .diff_index_to_workdir(None, Some(&mut opts))
20 .context("failed to diff working directory against index")?;
21 parse_diff(&diff)
22}
23
24pub fn get_staged_diff(repo: &Repository) -> Result<Vec<DiffInfo>> {
28 let head_tree = match repo.head() {
29 Ok(reference) => {
30 let commit = reference
31 .peel_to_commit()
32 .context("HEAD does not point to a commit")?;
33 Some(commit.tree().context("commit has no tree")?)
34 }
35 Err(_) => None,
37 };
38
39 let diff = repo
40 .diff_tree_to_index(head_tree.as_ref(), None, None)
41 .context("failed to diff index against HEAD tree")?;
42 parse_diff(&diff)
43}
44
45pub fn get_commit_diff(repo: &Repository, oid_str: &str) -> Result<Vec<DiffInfo>> {
49 let oid =
50 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
51 let commit = repo
52 .find_commit(oid)
53 .with_context(|| format!("commit {oid_str} not found"))?;
54 let commit_tree = commit.tree().context("commit has no tree")?;
55
56 let parent_tree = if commit.parent_count() > 0 {
57 let parent = commit.parent(0).context("failed to read parent commit")?;
58 Some(parent.tree().context("parent commit has no tree")?)
59 } else {
60 None
61 };
62
63 let mut opts = DiffOptions::new();
64 let diff = repo
65 .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts))
66 .context("failed to diff commit against parent")?;
67 parse_diff(&diff)
68}
69
70pub fn get_commit_range_diff(
76 repo: &Repository,
77 oldest_oid_str: &str,
78 newest_oid_str: &str,
79) -> Result<Vec<DiffInfo>> {
80 let oldest_oid = git2::Oid::from_str(oldest_oid_str)
81 .with_context(|| format!("invalid OID: {oldest_oid_str}"))?;
82 let oldest_commit = repo
83 .find_commit(oldest_oid)
84 .with_context(|| format!("commit {oldest_oid_str} not found"))?;
85
86 let start_tree = if oldest_commit.parent_count() > 0 {
88 let parent = oldest_commit.parent(0).context("failed to read parent")?;
89 Some(parent.tree().context("parent has no tree")?)
90 } else {
91 None
92 };
93
94 let newest_oid = git2::Oid::from_str(newest_oid_str)
95 .with_context(|| format!("invalid OID: {newest_oid_str}"))?;
96 let newest_commit = repo
97 .find_commit(newest_oid)
98 .with_context(|| format!("commit {newest_oid_str} not found"))?;
99 let end_tree = newest_commit.tree().context("newest commit has no tree")?;
100
101 let diff = repo
102 .diff_tree_to_tree(start_tree.as_ref(), Some(&end_tree), None)
103 .context("failed to compute range diff")?;
104 parse_diff(&diff)
105}
106
107pub fn checkout_file_at_commit(repo: &Repository, oid_str: &str, file_path: &str) -> Result<()> {
112 let oid =
113 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
114 let commit = repo
115 .find_commit(oid)
116 .with_context(|| format!("commit {oid_str} not found"))?;
117 let tree = commit.tree().context("commit has no tree")?;
118
119 let mut opts = git2::build::CheckoutBuilder::new();
120 opts.path(file_path).force().update_index(true);
121
122 repo.checkout_tree(&tree.into_object(), Some(&mut opts))
123 .with_context(|| format!("failed to checkout '{file_path}' from commit {oid_str}"))?;
124
125 Ok(())
126}
127
128pub fn get_commit_file_list(
134 repo: &Repository,
135 oid_str: &str,
136) -> Result<Vec<super::types::DiffFileEntry>> {
137 let oid =
138 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
139 let commit = repo
140 .find_commit(oid)
141 .with_context(|| format!("commit {oid_str} not found"))?;
142 let commit_tree = commit.tree().context("commit has no tree")?;
143
144 let parent_tree = if commit.parent_count() > 0 {
145 let parent = commit.parent(0).context("failed to read parent commit")?;
146 Some(parent.tree().context("parent commit has no tree")?)
147 } else {
148 None
149 };
150
151 let diff = repo
152 .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)
153 .context("failed to diff commit against parent")?;
154
155 Ok(diff
156 .deltas()
157 .map(|delta| super::types::DiffFileEntry {
158 old_file: delta
159 .old_file()
160 .path()
161 .map(|p| p.to_string_lossy().into_owned())
162 .unwrap_or_default(),
163 new_file: delta
164 .new_file()
165 .path()
166 .map(|p| p.to_string_lossy().into_owned())
167 .unwrap_or_default(),
168 status: FileStatus::from_delta(delta.status()),
169 })
170 .collect())
171}
172
173pub fn get_single_file_diff(repo: &Repository, oid_str: &str, file_path: &str) -> Result<DiffInfo> {
178 let oid =
179 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
180 let commit = repo
181 .find_commit(oid)
182 .with_context(|| format!("commit {oid_str} not found"))?;
183 let commit_tree = commit.tree().context("commit has no tree")?;
184
185 let parent_tree = if commit.parent_count() > 0 {
186 let parent = commit.parent(0).context("failed to read parent commit")?;
187 Some(parent.tree().context("parent commit has no tree")?)
188 } else {
189 None
190 };
191
192 let mut opts = DiffOptions::new();
193 opts.pathspec(file_path);
194
195 let diff = repo
196 .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts))
197 .context("failed to diff commit against parent for single file")?;
198
199 let infos = parse_diff(&diff)?;
200 infos
201 .into_iter()
202 .next()
203 .ok_or_else(|| anyhow::anyhow!("file '{}' not found in commit diff", file_path))
204}
205
206pub fn diff_file_commit_vs_workdir(
212 repo: &Repository,
213 oid_str: &str,
214 file_path: &str,
215) -> Result<DiffInfo> {
216 let oid =
217 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
218 let commit = repo
219 .find_commit(oid)
220 .with_context(|| format!("commit {oid_str} not found"))?;
221 let commit_tree = commit.tree().context("commit has no tree")?;
222
223 let mut opts = DiffOptions::new();
224 opts.pathspec(file_path);
225
226 let diff = repo
228 .diff_tree_to_workdir_with_index(Some(&commit_tree), Some(&mut opts))
229 .context("failed to diff commit tree against working directory")?;
230
231 let infos = parse_diff(&diff)?;
232
233 if let Some(info) = infos.into_iter().next() {
234 return Ok(info);
235 }
236
237 let in_commit = commit_tree
239 .get_path(std::path::Path::new(file_path))
240 .is_ok();
241
242 let workdir = repo.workdir().context("bare repository")?;
244 let in_workdir = workdir.join(file_path).exists();
245
246 match (in_commit, in_workdir) {
247 (true, true) => {
248 Ok(DiffInfo {
250 old_file: file_path.to_string(),
251 new_file: file_path.to_string(),
252 status: FileStatus::Modified,
253 hunks: vec![DiffHunk {
254 header: "@@ No changes — file is identical @@".to_string(),
255 lines: vec![DiffLine::HunkHeader(
256 "@@ No changes — file is identical to working tree @@".to_string(),
257 )],
258 }],
259 })
260 }
261 (true, false) => {
262 let blob_entry = commit_tree.get_path(std::path::Path::new(file_path))?;
264 let mut hunks = Vec::new();
265 if let Ok(blob) = repo.find_blob(blob_entry.id()) {
266 let content = String::from_utf8_lossy(blob.content());
267 let lines: Vec<DiffLine> = std::iter::once(DiffLine::HunkHeader(format!(
268 "@@ File deleted since commit {} @@",
269 &oid_str[..7.min(oid_str.len())]
270 )))
271 .chain(content.lines().map(|l| DiffLine::Deletion(l.to_string())))
272 .collect();
273
274 hunks.push(DiffHunk {
275 header: lines
276 .first()
277 .map(|l| match l {
278 DiffLine::HunkHeader(h) => h.clone(),
279 _ => String::new(),
280 })
281 .unwrap_or_default(),
282 lines,
283 });
284 }
285
286 Ok(DiffInfo {
287 old_file: file_path.to_string(),
288 new_file: String::new(),
289 status: FileStatus::Deleted,
290 hunks,
291 })
292 }
293 (false, true) => {
294 Err(anyhow::anyhow!(
296 "file '{}' did not exist at commit {} — it was added later",
297 file_path,
298 &oid_str[..7.min(oid_str.len())]
299 ))
300 }
301 (false, false) => Err(anyhow::anyhow!(
302 "file '{}' not found in commit {} or working tree — it may have been renamed",
303 file_path,
304 &oid_str[..7.min(oid_str.len())]
305 )),
306 }
307}
308
309pub fn file_list_commit_vs_workdir(
311 repo: &Repository,
312 oid_str: &str,
313) -> Result<Vec<super::types::DiffFileEntry>> {
314 let oid =
315 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
316 let commit = repo
317 .find_commit(oid)
318 .with_context(|| format!("commit {oid_str} not found"))?;
319 let commit_tree = commit.tree().context("commit has no tree")?;
320
321 let diff = repo
322 .diff_tree_to_workdir_with_index(Some(&commit_tree), None)
323 .context("failed to diff commit tree against working directory")?;
324
325 Ok(diff
326 .deltas()
327 .map(|delta| super::types::DiffFileEntry {
328 old_file: delta
329 .old_file()
330 .path()
331 .map(|p| p.to_string_lossy().into_owned())
332 .unwrap_or_default(),
333 new_file: delta
334 .new_file()
335 .path()
336 .map(|p| p.to_string_lossy().into_owned())
337 .unwrap_or_default(),
338 status: FileStatus::from_delta(delta.status()),
339 })
340 .collect())
341}
342
343fn parse_diff(diff: &Diff<'_>) -> Result<Vec<DiffInfo>> {
348 let num_deltas = diff.deltas().len();
349 let mut infos: Vec<DiffInfo> = Vec::with_capacity(num_deltas);
350
351 for delta in diff.deltas() {
354 let old_file = delta
355 .old_file()
356 .path()
357 .map(|p| p.to_string_lossy().into_owned())
358 .unwrap_or_default();
359 let new_file = delta
360 .new_file()
361 .path()
362 .map(|p| p.to_string_lossy().into_owned())
363 .unwrap_or_default();
364 let status = FileStatus::from_delta(delta.status());
365 infos.push(DiffInfo {
366 old_file,
367 new_file,
368 status,
369 hunks: Vec::new(),
370 });
371 }
372
373 let mut current_delta_idx: usize = 0;
376
377 diff.print(DiffFormat::Patch, |delta, maybe_hunk, line| {
378 let delta_new = delta
380 .new_file()
381 .path()
382 .map(|p| p.to_string_lossy().into_owned())
383 .unwrap_or_default();
384 let delta_old = delta
385 .old_file()
386 .path()
387 .map(|p| p.to_string_lossy().into_owned())
388 .unwrap_or_default();
389
390 let found_idx = infos[current_delta_idx..]
392 .iter()
393 .position(|info| info.new_file == delta_new && info.old_file == delta_old)
394 .map(|pos| pos + current_delta_idx)
395 .or_else(|| {
396 infos[..current_delta_idx]
398 .iter()
399 .position(|info| info.new_file == delta_new && info.old_file == delta_old)
400 });
401
402 let found = found_idx.is_some();
403 if let Some(idx) = found_idx {
404 current_delta_idx = idx;
405 }
406 if !found {
407 return true; }
409
410 let info = &mut infos[current_delta_idx];
411
412 if let Some(hunk) = maybe_hunk {
414 let header = String::from_utf8_lossy(hunk.header())
415 .trim_end()
416 .to_string();
417
418 let needs_new = match info.hunks.last() {
420 Some(h) => h.header != header,
421 None => true,
422 };
423 if needs_new {
424 info.hunks.push(DiffHunk {
425 header: header.clone(),
426 lines: vec![DiffLine::HunkHeader(header)],
427 });
428 }
429 }
430
431 if let Some(hunk) = info.hunks.last_mut() {
433 let content = String::from_utf8_lossy(line.content())
434 .trim_end_matches('\n')
435 .trim_end_matches('\r')
436 .to_string();
437
438 let diff_line = match line.origin() {
439 '+' | '>' => DiffLine::Addition(content),
440 '-' | '<' => DiffLine::Deletion(content),
441 ' ' => DiffLine::Context(content),
442 _ => return true,
445 };
446 hunk.lines.push(diff_line);
447 }
448
449 true
450 })
451 .context("failed to walk diff")?;
452
453 Ok(infos)
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use std::fs;
460
461 fn init_repo_with_commit(dir: &std::path::Path) -> git2::Repository {
462 let repo = git2::Repository::init(dir).unwrap();
463 {
464 let file_path = dir.join("hello.txt");
465 fs::write(&file_path, "Hello, world!\n").unwrap();
466
467 let mut index = repo.index().unwrap();
468 index.add_path(std::path::Path::new("hello.txt")).unwrap();
469 index.write().unwrap();
470
471 let tree_oid = index.write_tree().unwrap();
472 let tree = repo.find_tree(tree_oid).unwrap();
473 let sig = git2::Signature::now("Test", "test@test.com").unwrap();
474 repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
475 .unwrap();
476 }
477 repo
478 }
479
480 #[test]
481 fn working_dir_diff_shows_changes() {
482 let tmp = tempfile::tempdir().unwrap();
483 let repo = init_repo_with_commit(tmp.path());
484
485 fs::write(tmp.path().join("hello.txt"), "Hello, modified!\n").unwrap();
487
488 let diffs = get_working_dir_diff(&repo).unwrap();
489 assert_eq!(diffs.len(), 1);
490 assert_eq!(diffs[0].new_file, "hello.txt");
491 assert_eq!(diffs[0].status, FileStatus::Modified);
492 assert!(!diffs[0].hunks.is_empty());
493 }
494
495 #[test]
496 fn staged_diff_shows_staged_changes() {
497 let tmp = tempfile::tempdir().unwrap();
498 let repo = init_repo_with_commit(tmp.path());
499
500 fs::write(tmp.path().join("hello.txt"), "Hello, staged!\n").unwrap();
502 let mut index = repo.index().unwrap();
503 index.add_path(std::path::Path::new("hello.txt")).unwrap();
504 index.write().unwrap();
505
506 let diffs = get_staged_diff(&repo).unwrap();
507 assert_eq!(diffs.len(), 1);
508 assert_eq!(diffs[0].new_file, "hello.txt");
509 assert_eq!(diffs[0].status, FileStatus::Modified);
510 }
511
512 #[test]
513 fn commit_diff_shows_initial_commit() {
514 let tmp = tempfile::tempdir().unwrap();
515 let repo = init_repo_with_commit(tmp.path());
516
517 let head_oid = repo.head().unwrap().target().unwrap().to_string();
518 let diffs = get_commit_diff(&repo, &head_oid).unwrap();
519 assert_eq!(diffs.len(), 1);
520 assert_eq!(diffs[0].new_file, "hello.txt");
521 assert_eq!(diffs[0].status, FileStatus::New);
522 }
523
524 #[test]
525 fn working_dir_diff_untracked_file() {
526 let tmp = tempfile::tempdir().unwrap();
527 let repo = init_repo_with_commit(tmp.path());
528
529 fs::write(tmp.path().join("new_file.txt"), "I am new!\n").unwrap();
531
532 let diffs = get_working_dir_diff(&repo).unwrap();
533 assert_eq!(diffs.len(), 1);
534 assert_eq!(diffs[0].new_file, "new_file.txt");
535 assert_eq!(diffs[0].status, FileStatus::Untracked);
536 }
537
538 #[test]
539 fn commit_file_list_returns_entries() {
540 let tmp = tempfile::tempdir().unwrap();
541 let repo = init_repo_with_commit(tmp.path());
542 let head_oid = repo.head().unwrap().target().unwrap().to_string();
543 let files = get_commit_file_list(&repo, &head_oid).unwrap();
544 assert_eq!(files.len(), 1);
545 assert_eq!(files[0].new_file, "hello.txt");
546 assert_eq!(files[0].status, FileStatus::New);
547 assert_eq!(files[0].display_path(), "hello.txt");
548 }
549
550 #[test]
551 fn single_file_diff_returns_correct_file() {
552 let tmp = tempfile::tempdir().unwrap();
553 let repo = init_repo_with_commit(tmp.path());
554 let head_oid = repo.head().unwrap().target().unwrap().to_string();
555 let diff = get_single_file_diff(&repo, &head_oid, "hello.txt").unwrap();
556 assert_eq!(diff.new_file, "hello.txt");
557 assert_eq!(diff.status, FileStatus::New);
558 assert!(!diff.hunks.is_empty());
559 }
560
561 #[test]
562 fn diff_file_commit_vs_workdir_shows_changes() {
563 let tmp = tempfile::tempdir().unwrap();
564 let repo = init_repo_with_commit(tmp.path());
565 let head_oid = repo.head().unwrap().target().unwrap().to_string();
566
567 std::fs::write(tmp.path().join("hello.txt"), "Modified content!\n").unwrap();
569
570 let diff = diff_file_commit_vs_workdir(&repo, &head_oid, "hello.txt").unwrap();
571 assert_eq!(diff.new_file, "hello.txt");
572 assert!(!diff.hunks.is_empty());
573 }
574
575 #[test]
576 fn file_list_commit_vs_workdir_detects_modified() {
577 let tmp = tempfile::tempdir().unwrap();
578 let repo = init_repo_with_commit(tmp.path());
579 let head_oid = repo.head().unwrap().target().unwrap().to_string();
580
581 std::fs::write(tmp.path().join("hello.txt"), "Changed!\n").unwrap();
583
584 let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
585 assert!(!files.is_empty());
586 assert_eq!(files[0].display_path(), "hello.txt");
587 assert_eq!(files[0].status, FileStatus::Modified);
588 }
589
590 #[test]
591 fn file_list_commit_vs_workdir_detects_new_file() {
592 let tmp = tempfile::tempdir().unwrap();
593 let repo = init_repo_with_commit(tmp.path());
594 let head_oid = repo.head().unwrap().target().unwrap().to_string();
595
596 std::fs::write(tmp.path().join("new_file.txt"), "new\n").unwrap();
598 let mut index = repo.index().unwrap();
599 index
600 .add_path(std::path::Path::new("new_file.txt"))
601 .unwrap();
602 index.write().unwrap();
603
604 let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
605 let new = files.iter().find(|f| f.display_path() == "new_file.txt");
606 assert!(new.is_some(), "new_file.txt should appear in the diff list");
607 }
608
609 #[test]
610 fn file_list_commit_vs_workdir_detects_deletion() {
611 let tmp = tempfile::tempdir().unwrap();
612 let repo = init_repo_with_commit(tmp.path());
613 let head_oid = repo.head().unwrap().target().unwrap().to_string();
614
615 std::fs::remove_file(tmp.path().join("hello.txt")).unwrap();
617
618 let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
619 let deleted = files.iter().find(|f| f.display_path() == "hello.txt");
620 assert!(deleted.is_some());
621 assert_eq!(deleted.unwrap().status, FileStatus::Deleted);
622 }
623
624 #[test]
625 fn file_list_commit_vs_workdir_empty_when_unchanged() {
626 let tmp = tempfile::tempdir().unwrap();
627 let repo = init_repo_with_commit(tmp.path());
628 let head_oid = repo.head().unwrap().target().unwrap().to_string();
629
630 let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
632 assert!(
633 files.is_empty(),
634 "should be empty when working tree matches commit"
635 );
636 }
637
638 #[test]
639 fn checkout_file_at_commit_restores_file() {
640 let dir = tempfile::tempdir().unwrap();
641 let repo = init_repo_with_commit(dir.path());
642 let head_oid = repo
644 .head()
645 .unwrap()
646 .peel_to_commit()
647 .unwrap()
648 .id()
649 .to_string();
650 std::fs::write(dir.path().join("hello.txt"), "modified content").unwrap();
652 checkout_file_at_commit(&repo, &head_oid, "hello.txt").unwrap();
654 let content = std::fs::read_to_string(dir.path().join("hello.txt")).unwrap();
655 assert_eq!(content, "Hello, world!\n");
656 }
657
658 #[test]
659 fn range_diff_single_commit_matches_commit_diff() {
660 let dir = tempfile::tempdir().unwrap();
661 let repo = init_repo_with_commit(dir.path());
662 let oid = repo
663 .head()
664 .unwrap()
665 .peel_to_commit()
666 .unwrap()
667 .id()
668 .to_string();
669 let range = get_commit_range_diff(&repo, &oid, &oid).unwrap();
671 let single = get_commit_diff(&repo, &oid).unwrap();
672 assert_eq!(range.len(), single.len());
673 }
674
675 #[test]
676 fn single_file_diff_not_found() {
677 let tmp = tempfile::tempdir().unwrap();
678 let repo = init_repo_with_commit(tmp.path());
679 let head_oid = repo.head().unwrap().target().unwrap().to_string();
680 let result = get_single_file_diff(&repo, &head_oid, "nonexistent.txt");
681 assert!(result.is_err());
682 }
683}