gitkraft_core/features/diff/
ops.rs1use anyhow::{Context, Result};
4use git2::{Diff, DiffFormat, DiffOptions, Repository};
5
6use super::types::{DiffFileEntry, 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_working_dir_file_list(repo: &Repository) -> Result<Vec<DiffFileEntry>> {
50 let mut opts = DiffOptions::new();
51 opts.include_untracked(true).recurse_untracked_dirs(true);
52 let diff = repo
53 .diff_index_to_workdir(None, Some(&mut opts))
54 .context("failed to diff index to workdir")?;
55 Ok(diff_file_entries(&diff))
56}
57
58pub fn get_working_dir_single_file_diff(repo: &Repository, file_path: &str) -> Result<DiffInfo> {
62 let mut opts = DiffOptions::new();
63 opts.include_untracked(true)
64 .recurse_untracked_dirs(true)
65 .pathspec(file_path);
66 let diff = repo
67 .diff_index_to_workdir(None, Some(&mut opts))
68 .context("failed to diff index to workdir for single file")?;
69 let infos = parse_diff(&diff)?;
70 infos
71 .into_iter()
72 .next()
73 .ok_or_else(|| anyhow::anyhow!("file '{}' not found in working-dir diff", file_path))
74}
75
76pub fn get_staged_single_file_diff(repo: &Repository, file_path: &str) -> Result<DiffInfo> {
80 let head_tree = repo.head().ok().and_then(|r| r.peel_to_tree().ok());
81 let mut opts = DiffOptions::new();
82 opts.pathspec(file_path);
83 let diff = repo
84 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))
85 .context("failed to diff HEAD to index for single file")?;
86 let infos = parse_diff(&diff)?;
87 infos
88 .into_iter()
89 .next()
90 .ok_or_else(|| anyhow::anyhow!("file '{}' not found in staged diff", file_path))
91}
92
93pub fn get_staged_file_list(repo: &Repository) -> Result<Vec<DiffFileEntry>> {
98 let head_tree = repo.head().ok().and_then(|r| r.peel_to_tree().ok());
99 let diff = repo
100 .diff_tree_to_index(head_tree.as_ref(), None, None)
101 .context("failed to diff HEAD to index")?;
102 Ok(diff_file_entries(&diff))
103}
104
105pub fn get_commit_diff(repo: &Repository, oid_str: &str) -> Result<Vec<DiffInfo>> {
109 let oid =
110 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
111 let commit = repo
112 .find_commit(oid)
113 .with_context(|| format!("commit {oid_str} not found"))?;
114 let commit_tree = commit.tree().context("commit has no tree")?;
115
116 let parent_tree = if commit.parent_count() > 0 {
117 let parent = commit.parent(0).context("failed to read parent commit")?;
118 Some(parent.tree().context("parent commit has no tree")?)
119 } else {
120 None
121 };
122
123 let mut opts = DiffOptions::new();
124 let diff = repo
125 .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts))
126 .context("failed to diff commit against parent")?;
127 parse_diff(&diff)
128}
129
130pub fn get_commit_range_diff(
136 repo: &Repository,
137 oldest_oid_str: &str,
138 newest_oid_str: &str,
139) -> Result<Vec<DiffInfo>> {
140 let oldest_oid = git2::Oid::from_str(oldest_oid_str)
141 .with_context(|| format!("invalid OID: {oldest_oid_str}"))?;
142 let oldest_commit = repo
143 .find_commit(oldest_oid)
144 .with_context(|| format!("commit {oldest_oid_str} not found"))?;
145
146 let start_tree = if oldest_commit.parent_count() > 0 {
148 let parent = oldest_commit.parent(0).context("failed to read parent")?;
149 Some(parent.tree().context("parent has no tree")?)
150 } else {
151 None
152 };
153
154 let newest_oid = git2::Oid::from_str(newest_oid_str)
155 .with_context(|| format!("invalid OID: {newest_oid_str}"))?;
156 let newest_commit = repo
157 .find_commit(newest_oid)
158 .with_context(|| format!("commit {newest_oid_str} not found"))?;
159 let end_tree = newest_commit.tree().context("newest commit has no tree")?;
160
161 let diff = repo
162 .diff_tree_to_tree(start_tree.as_ref(), Some(&end_tree), None)
163 .context("failed to compute range diff")?;
164 parse_diff(&diff)
165}
166
167pub fn checkout_file_at_commit(repo: &Repository, oid_str: &str, file_path: &str) -> Result<()> {
172 let oid =
173 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
174 let commit = repo
175 .find_commit(oid)
176 .with_context(|| format!("commit {oid_str} not found"))?;
177 let tree = commit.tree().context("commit has no tree")?;
178
179 let mut opts = git2::build::CheckoutBuilder::new();
180 opts.path(file_path).force().update_index(true);
181
182 repo.checkout_tree(&tree.into_object(), Some(&mut opts))
183 .with_context(|| format!("failed to checkout '{file_path}' from commit {oid_str}"))?;
184
185 Ok(())
186}
187
188pub fn get_commit_file_list(
194 repo: &Repository,
195 oid_str: &str,
196) -> Result<Vec<super::types::DiffFileEntry>> {
197 let oid =
198 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
199 let commit = repo
200 .find_commit(oid)
201 .with_context(|| format!("commit {oid_str} not found"))?;
202 let commit_tree = commit.tree().context("commit has no tree")?;
203
204 let parent_tree = if commit.parent_count() > 0 {
205 let parent = commit.parent(0).context("failed to read parent commit")?;
206 Some(parent.tree().context("parent commit has no tree")?)
207 } else {
208 None
209 };
210
211 let diff = repo
212 .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)
213 .context("failed to diff commit against parent")?;
214
215 Ok(diff_file_entries(&diff))
216}
217
218pub fn get_single_file_diff(repo: &Repository, oid_str: &str, file_path: &str) -> Result<DiffInfo> {
223 let oid =
224 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
225 let commit = repo
226 .find_commit(oid)
227 .with_context(|| format!("commit {oid_str} not found"))?;
228 let commit_tree = commit.tree().context("commit has no tree")?;
229
230 let parent_tree = if commit.parent_count() > 0 {
231 let parent = commit.parent(0).context("failed to read parent commit")?;
232 Some(parent.tree().context("parent commit has no tree")?)
233 } else {
234 None
235 };
236
237 let mut opts = DiffOptions::new();
238 opts.pathspec(file_path);
239
240 let diff = repo
241 .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts))
242 .context("failed to diff commit against parent for single file")?;
243
244 let infos = parse_diff(&diff)?;
245 infos
246 .into_iter()
247 .next()
248 .ok_or_else(|| anyhow::anyhow!("file '{}' not found in commit diff", file_path))
249}
250
251pub fn diff_file_commit_vs_workdir(
257 repo: &Repository,
258 oid_str: &str,
259 file_path: &str,
260) -> Result<DiffInfo> {
261 let oid =
262 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
263 let commit = repo
264 .find_commit(oid)
265 .with_context(|| format!("commit {oid_str} not found"))?;
266 let commit_tree = commit.tree().context("commit has no tree")?;
267
268 let mut opts = DiffOptions::new();
269 opts.pathspec(file_path);
270
271 let diff = repo
273 .diff_tree_to_workdir_with_index(Some(&commit_tree), Some(&mut opts))
274 .context("failed to diff commit tree against working directory")?;
275
276 let infos = parse_diff(&diff)?;
277
278 if let Some(info) = infos.into_iter().next() {
279 return Ok(info);
280 }
281
282 let in_commit = commit_tree
284 .get_path(std::path::Path::new(file_path))
285 .is_ok();
286
287 let workdir = repo.workdir().context("bare repository")?;
289 let in_workdir = workdir.join(file_path).exists();
290
291 match (in_commit, in_workdir) {
292 (true, true) => {
293 Ok(DiffInfo {
295 old_file: file_path.to_string(),
296 new_file: file_path.to_string(),
297 status: FileStatus::Modified,
298 hunks: vec![DiffHunk {
299 header: "@@ No changes — file is identical @@".to_string(),
300 lines: vec![DiffLine::HunkHeader(
301 "@@ No changes — file is identical to working tree @@".to_string(),
302 )],
303 }],
304 })
305 }
306 (true, false) => {
307 let blob_entry = commit_tree.get_path(std::path::Path::new(file_path))?;
309 let mut hunks = Vec::new();
310 if let Ok(blob) = repo.find_blob(blob_entry.id()) {
311 let content = String::from_utf8_lossy(blob.content());
312 let lines: Vec<DiffLine> = std::iter::once(DiffLine::HunkHeader(format!(
313 "@@ File deleted since commit {} @@",
314 &oid_str[..7.min(oid_str.len())]
315 )))
316 .chain(content.lines().map(|l| DiffLine::Deletion(l.to_string())))
317 .collect();
318
319 hunks.push(DiffHunk {
320 header: lines
321 .first()
322 .map(|l| match l {
323 DiffLine::HunkHeader(h) => h.clone(),
324 _ => String::new(),
325 })
326 .unwrap_or_default(),
327 lines,
328 });
329 }
330
331 Ok(DiffInfo {
332 old_file: file_path.to_string(),
333 new_file: String::new(),
334 status: FileStatus::Deleted,
335 hunks,
336 })
337 }
338 (false, true) => {
339 Err(anyhow::anyhow!(
341 "file '{}' did not exist at commit {} — it was added later",
342 file_path,
343 &oid_str[..7.min(oid_str.len())]
344 ))
345 }
346 (false, false) => Err(anyhow::anyhow!(
347 "file '{}' not found in commit {} or working tree — it may have been renamed",
348 file_path,
349 &oid_str[..7.min(oid_str.len())]
350 )),
351 }
352}
353
354pub fn file_list_commit_vs_workdir(
356 repo: &Repository,
357 oid_str: &str,
358) -> Result<Vec<super::types::DiffFileEntry>> {
359 let oid =
360 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
361 let commit = repo
362 .find_commit(oid)
363 .with_context(|| format!("commit {oid_str} not found"))?;
364 let commit_tree = commit.tree().context("commit has no tree")?;
365
366 let diff = repo
367 .diff_tree_to_workdir_with_index(Some(&commit_tree), None)
368 .context("failed to diff commit tree against working directory")?;
369
370 Ok(diff_file_entries(&diff))
371}
372
373fn diff_file_entries(diff: &Diff<'_>) -> Vec<DiffFileEntry> {
377 diff.deltas()
378 .map(|delta| DiffFileEntry {
379 old_file: delta
380 .old_file()
381 .path()
382 .map(|p| p.to_string_lossy().into_owned())
383 .unwrap_or_default(),
384 new_file: delta
385 .new_file()
386 .path()
387 .map(|p| p.to_string_lossy().into_owned())
388 .unwrap_or_default(),
389 status: FileStatus::from_delta(delta.status()),
390 })
391 .collect()
392}
393
394fn parse_diff(diff: &Diff<'_>) -> Result<Vec<DiffInfo>> {
397 let num_deltas = diff.deltas().len();
398 let mut infos: Vec<DiffInfo> = Vec::with_capacity(num_deltas);
399
400 for delta in diff.deltas() {
403 let old_file = delta
404 .old_file()
405 .path()
406 .map(|p| p.to_string_lossy().into_owned())
407 .unwrap_or_default();
408 let new_file = delta
409 .new_file()
410 .path()
411 .map(|p| p.to_string_lossy().into_owned())
412 .unwrap_or_default();
413 let status = FileStatus::from_delta(delta.status());
414 infos.push(DiffInfo {
415 old_file,
416 new_file,
417 status,
418 hunks: Vec::new(),
419 });
420 }
421
422 let mut current_delta_idx: usize = 0;
425
426 diff.print(DiffFormat::Patch, |delta, maybe_hunk, line| {
427 let delta_new = delta
429 .new_file()
430 .path()
431 .map(|p| p.to_string_lossy().into_owned())
432 .unwrap_or_default();
433 let delta_old = delta
434 .old_file()
435 .path()
436 .map(|p| p.to_string_lossy().into_owned())
437 .unwrap_or_default();
438
439 let found_idx = infos[current_delta_idx..]
441 .iter()
442 .position(|info| info.new_file == delta_new && info.old_file == delta_old)
443 .map(|pos| pos + current_delta_idx)
444 .or_else(|| {
445 infos[..current_delta_idx]
447 .iter()
448 .position(|info| info.new_file == delta_new && info.old_file == delta_old)
449 });
450
451 let found = found_idx.is_some();
452 if let Some(idx) = found_idx {
453 current_delta_idx = idx;
454 }
455 if !found {
456 return true; }
458
459 let info = &mut infos[current_delta_idx];
460
461 if let Some(hunk) = maybe_hunk {
463 let header = String::from_utf8_lossy(hunk.header())
464 .trim_end()
465 .to_string();
466
467 let needs_new = match info.hunks.last() {
469 Some(h) => h.header != header,
470 None => true,
471 };
472 if needs_new {
473 info.hunks.push(DiffHunk {
474 header: header.clone(),
475 lines: vec![DiffLine::HunkHeader(header)],
476 });
477 }
478 }
479
480 if let Some(hunk) = info.hunks.last_mut() {
482 let content = String::from_utf8_lossy(line.content())
483 .trim_end_matches('\n')
484 .trim_end_matches('\r')
485 .to_string();
486
487 let diff_line = match line.origin() {
488 '+' | '>' => DiffLine::Addition(content),
489 '-' | '<' => DiffLine::Deletion(content),
490 ' ' => DiffLine::Context(content),
491 _ => return true,
494 };
495 hunk.lines.push(diff_line);
496 }
497
498 true
499 })
500 .context("failed to walk diff")?;
501
502 Ok(infos)
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use std::fs;
509
510 fn init_repo_with_commit(dir: &std::path::Path) -> git2::Repository {
511 let repo = git2::Repository::init(dir).unwrap();
512 {
513 let file_path = dir.join("hello.txt");
514 fs::write(&file_path, "Hello, world!\n").unwrap();
515
516 let mut index = repo.index().unwrap();
517 index.add_path(std::path::Path::new("hello.txt")).unwrap();
518 index.write().unwrap();
519
520 let tree_oid = index.write_tree().unwrap();
521 let tree = repo.find_tree(tree_oid).unwrap();
522 let sig = git2::Signature::now("Test", "test@test.com").unwrap();
523 repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
524 .unwrap();
525 }
526 repo
527 }
528
529 #[test]
530 fn working_dir_diff_shows_changes() {
531 let tmp = tempfile::tempdir().unwrap();
532 let repo = init_repo_with_commit(tmp.path());
533
534 fs::write(tmp.path().join("hello.txt"), "Hello, modified!\n").unwrap();
536
537 let diffs = get_working_dir_diff(&repo).unwrap();
538 assert_eq!(diffs.len(), 1);
539 assert_eq!(diffs[0].new_file, "hello.txt");
540 assert_eq!(diffs[0].status, FileStatus::Modified);
541 assert!(!diffs[0].hunks.is_empty());
542 }
543
544 #[test]
545 fn staged_diff_shows_staged_changes() {
546 let tmp = tempfile::tempdir().unwrap();
547 let repo = init_repo_with_commit(tmp.path());
548
549 fs::write(tmp.path().join("hello.txt"), "Hello, staged!\n").unwrap();
551 let mut index = repo.index().unwrap();
552 index.add_path(std::path::Path::new("hello.txt")).unwrap();
553 index.write().unwrap();
554
555 let diffs = get_staged_diff(&repo).unwrap();
556 assert_eq!(diffs.len(), 1);
557 assert_eq!(diffs[0].new_file, "hello.txt");
558 assert_eq!(diffs[0].status, FileStatus::Modified);
559 }
560
561 #[test]
562 fn commit_diff_shows_initial_commit() {
563 let tmp = tempfile::tempdir().unwrap();
564 let repo = init_repo_with_commit(tmp.path());
565
566 let head_oid = repo.head().unwrap().target().unwrap().to_string();
567 let diffs = get_commit_diff(&repo, &head_oid).unwrap();
568 assert_eq!(diffs.len(), 1);
569 assert_eq!(diffs[0].new_file, "hello.txt");
570 assert_eq!(diffs[0].status, FileStatus::New);
571 }
572
573 #[test]
574 fn working_dir_diff_untracked_file() {
575 let tmp = tempfile::tempdir().unwrap();
576 let repo = init_repo_with_commit(tmp.path());
577
578 fs::write(tmp.path().join("new_file.txt"), "I am new!\n").unwrap();
580
581 let diffs = get_working_dir_diff(&repo).unwrap();
582 assert_eq!(diffs.len(), 1);
583 assert_eq!(diffs[0].new_file, "new_file.txt");
584 assert_eq!(diffs[0].status, FileStatus::Untracked);
585 }
586
587 #[test]
588 fn commit_file_list_returns_entries() {
589 let tmp = tempfile::tempdir().unwrap();
590 let repo = init_repo_with_commit(tmp.path());
591 let head_oid = repo.head().unwrap().target().unwrap().to_string();
592 let files = get_commit_file_list(&repo, &head_oid).unwrap();
593 assert_eq!(files.len(), 1);
594 assert_eq!(files[0].new_file, "hello.txt");
595 assert_eq!(files[0].status, FileStatus::New);
596 assert_eq!(files[0].display_path(), "hello.txt");
597 }
598
599 #[test]
600 fn single_file_diff_returns_correct_file() {
601 let tmp = tempfile::tempdir().unwrap();
602 let repo = init_repo_with_commit(tmp.path());
603 let head_oid = repo.head().unwrap().target().unwrap().to_string();
604 let diff = get_single_file_diff(&repo, &head_oid, "hello.txt").unwrap();
605 assert_eq!(diff.new_file, "hello.txt");
606 assert_eq!(diff.status, FileStatus::New);
607 assert!(!diff.hunks.is_empty());
608 }
609
610 #[test]
611 fn diff_file_commit_vs_workdir_shows_changes() {
612 let tmp = tempfile::tempdir().unwrap();
613 let repo = init_repo_with_commit(tmp.path());
614 let head_oid = repo.head().unwrap().target().unwrap().to_string();
615
616 std::fs::write(tmp.path().join("hello.txt"), "Modified content!\n").unwrap();
618
619 let diff = diff_file_commit_vs_workdir(&repo, &head_oid, "hello.txt").unwrap();
620 assert_eq!(diff.new_file, "hello.txt");
621 assert!(!diff.hunks.is_empty());
622 }
623
624 #[test]
625 fn file_list_commit_vs_workdir_detects_modified() {
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 std::fs::write(tmp.path().join("hello.txt"), "Changed!\n").unwrap();
632
633 let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
634 assert!(!files.is_empty());
635 assert_eq!(files[0].display_path(), "hello.txt");
636 assert_eq!(files[0].status, FileStatus::Modified);
637 }
638
639 #[test]
640 fn file_list_commit_vs_workdir_detects_new_file() {
641 let tmp = tempfile::tempdir().unwrap();
642 let repo = init_repo_with_commit(tmp.path());
643 let head_oid = repo.head().unwrap().target().unwrap().to_string();
644
645 std::fs::write(tmp.path().join("new_file.txt"), "new\n").unwrap();
647 let mut index = repo.index().unwrap();
648 index
649 .add_path(std::path::Path::new("new_file.txt"))
650 .unwrap();
651 index.write().unwrap();
652
653 let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
654 let new = files.iter().find(|f| f.display_path() == "new_file.txt");
655 assert!(new.is_some(), "new_file.txt should appear in the diff list");
656 }
657
658 #[test]
659 fn file_list_commit_vs_workdir_detects_deletion() {
660 let tmp = tempfile::tempdir().unwrap();
661 let repo = init_repo_with_commit(tmp.path());
662 let head_oid = repo.head().unwrap().target().unwrap().to_string();
663
664 std::fs::remove_file(tmp.path().join("hello.txt")).unwrap();
666
667 let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
668 let deleted = files.iter().find(|f| f.display_path() == "hello.txt");
669 assert!(deleted.is_some());
670 assert_eq!(deleted.unwrap().status, FileStatus::Deleted);
671 }
672
673 #[test]
674 fn file_list_commit_vs_workdir_empty_when_unchanged() {
675 let tmp = tempfile::tempdir().unwrap();
676 let repo = init_repo_with_commit(tmp.path());
677 let head_oid = repo.head().unwrap().target().unwrap().to_string();
678
679 let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
681 assert!(
682 files.is_empty(),
683 "should be empty when working tree matches commit"
684 );
685 }
686
687 #[test]
688 fn checkout_file_at_commit_restores_file() {
689 let dir = tempfile::tempdir().unwrap();
690 let repo = init_repo_with_commit(dir.path());
691 let head_oid = repo
693 .head()
694 .unwrap()
695 .peel_to_commit()
696 .unwrap()
697 .id()
698 .to_string();
699 std::fs::write(dir.path().join("hello.txt"), "modified content").unwrap();
701 checkout_file_at_commit(&repo, &head_oid, "hello.txt").unwrap();
703 let content = std::fs::read_to_string(dir.path().join("hello.txt")).unwrap();
704 assert_eq!(content, "Hello, world!\n");
705 }
706
707 #[test]
708 fn range_diff_single_commit_matches_commit_diff() {
709 let dir = tempfile::tempdir().unwrap();
710 let repo = init_repo_with_commit(dir.path());
711 let oid = repo
712 .head()
713 .unwrap()
714 .peel_to_commit()
715 .unwrap()
716 .id()
717 .to_string();
718 let range = get_commit_range_diff(&repo, &oid, &oid).unwrap();
720 let single = get_commit_diff(&repo, &oid).unwrap();
721 assert_eq!(range.len(), single.len());
722 }
723
724 #[test]
725 fn single_file_diff_not_found() {
726 let tmp = tempfile::tempdir().unwrap();
727 let repo = init_repo_with_commit(tmp.path());
728 let head_oid = repo.head().unwrap().target().unwrap().to_string();
729 let result = get_single_file_diff(&repo, &head_oid, "nonexistent.txt");
730 assert!(result.is_err());
731 }
732}