Skip to main content

gitkraft_core/features/diff/
ops.rs

1//! Diff operations — working-directory, staged, and per-commit diffs.
2
3use anyhow::{Context, Result};
4use git2::{Diff, DiffFormat, DiffOptions, Repository};
5
6use super::types::{DiffHunk, DiffInfo, DiffLine, FileStatus};
7
8// ── Public API ────────────────────────────────────────────────────────────────
9
10/// Return the diff of unstaged (working-directory) changes against the index.
11///
12/// Includes untracked files.
13pub 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
24/// Return the diff of staged (index) changes against HEAD.
25///
26/// For an initial commit (no HEAD yet), diffs the full index as all-new files.
27pub 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        // No HEAD yet (empty repo) — diff the full index as "new"
36        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
45/// Return the diff introduced by a specific commit (compared to its first parent).
46///
47/// For a root commit (no parents), diffs against an empty tree.
48pub 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
70/// Return the combined diff across a range of commits.
71///
72/// Shows the net changes from the parent of `oldest_oid` to `newest_oid`
73/// (equivalent to `git diff <oldest>^ <newest>`).  When commits are in a
74/// consecutive range this collapses all intermediate changes into one diff.
75pub 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    // Start from the parent of the oldest commit (or empty tree for root)
87    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
107/// Restore a specific file from a commit to the working directory.
108///
109/// Equivalent to `git checkout <oid> -- <file_path>`.
110/// After calling this, the file in the working tree matches the committed version.
111pub 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
128/// Return just the list of changed files for a commit — no hunk / line parsing.
129///
130/// This is much faster than [`get_commit_diff`] because it only reads the
131/// tree-level delta metadata.  The GUI uses this to instantly populate the
132/// file sidebar when a commit is selected.
133pub 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
173/// Return the diff for a **single file** within a commit.
174///
175/// Uses `pathspec` filtering so that git2 only walks the hunks / lines for the
176/// requested file — much faster than parsing the entire commit diff.
177pub 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
206/// Return the diff of a file between a specific commit and the current working directory.
207///
208/// This lets the user compare an old revision of a file with their current changes.
209/// If the file no longer exists in the working tree, shows the entire file as
210/// deleted (all lines removed). If the file is identical, returns an empty diff.
211pub 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    // Diff: commit tree → working directory (including the index)
227    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    // Empty diff — check WHY it's empty.
238    let in_commit = commit_tree
239        .get_path(std::path::Path::new(file_path))
240        .is_ok();
241
242    // Check if file exists in the working tree
243    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            // File exists in both — no changes (identical)
249            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            // File exists in commit but not in working tree — show as all-deleted
263            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            // File exists in working tree but not in commit — new file since commit
295            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
309/// Get the list of files that differ between a commit and the current working directory.
310pub 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
343// ── Helpers ───────────────────────────────────────────────────────────────────
344
345/// Walk every delta / hunk / line in a `git2::Diff` and produce our domain
346/// `Vec<DiffInfo>`.
347fn 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    // Pre-populate DiffInfo shells for each delta so the print callback can
352    // index into them.
353    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    // Walk through the diff with the print callback which gives us
374    // file / hunk / line events in order.
375    let mut current_delta_idx: usize = 0;
376
377    diff.print(DiffFormat::Patch, |delta, maybe_hunk, line| {
378        // Identify which delta we are currently processing by matching paths.
379        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        // Find the matching DiffInfo — usually at current_delta_idx or later.
391        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                // Also search from the beginning in case deltas are reordered
397                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; // skip unknown delta
408        }
409
410        let info = &mut infos[current_delta_idx];
411
412        // If we have a hunk header, potentially create a new hunk.
413        if let Some(hunk) = maybe_hunk {
414            let header = String::from_utf8_lossy(hunk.header())
415                .trim_end()
416                .to_string();
417
418            // Only create a new hunk if the header differs from the current one.
419            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        // Map the line origin to our DiffLine type and append to the current hunk.
432        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                // File-level headers ('F'), binary notices ('B'), hunk header origin ('H')
443                // — we skip these as they are handled above or are informational.
444                _ => 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        // Modify the file
486        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        // Modify and stage the file
501        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        // Create a new untracked file
530        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        // Modify the file in the working directory
568        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        // Modify the file
582        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        // Add a new file and stage it
597        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        // Delete the committed file
616        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        // No changes
631        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        // Get the HEAD commit OID
643        let head_oid = repo
644            .head()
645            .unwrap()
646            .peel_to_commit()
647            .unwrap()
648            .id()
649            .to_string();
650        // Modify the file
651        std::fs::write(dir.path().join("hello.txt"), "modified content").unwrap();
652        // Restore using checkout_file_at_commit
653        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        // Range diff with the same commit as both ends should equal a single-commit diff
670        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}