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::{DiffFileEntry, 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/// Lightweight working-directory file list (no diff content).
46///
47/// Returns only paths and status for each changed file — no hunks or lines.
48/// Much cheaper than [`get_working_dir_diff`] for populating file lists.
49pub 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
58/// Return the full diff (with hunks/lines) for a **single unstaged file**.
59///
60/// Uses pathspec filtering so only the requested file is parsed.
61pub 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
76/// Return the full diff (with hunks/lines) for a **single staged file**.
77///
78/// Uses pathspec filtering so only the requested file is parsed.
79pub 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
93/// Lightweight staged file list (no diff content).
94///
95/// Returns only paths and status for each staged file — no hunks or lines.
96/// Much cheaper than [`get_staged_diff`] for populating file lists.
97pub 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
105/// Return the diff introduced by a specific commit (compared to its first parent).
106///
107/// For a root commit (no parents), diffs against an empty tree.
108pub 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
130/// Return the combined diff across a range of commits.
131///
132/// Shows the net changes from the parent of `oldest_oid` to `newest_oid`
133/// (equivalent to `git diff <oldest>^ <newest>`).  When commits are in a
134/// consecutive range this collapses all intermediate changes into one diff.
135pub 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    // Start from the parent of the oldest commit (or empty tree for root)
147    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
167/// Restore a specific file from a commit to the working directory.
168///
169/// Equivalent to `git checkout <oid> -- <file_path>`.
170/// After calling this, the file in the working tree matches the committed version.
171pub 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
188/// Return just the list of changed files for a commit — no hunk / line parsing.
189///
190/// This is much faster than [`get_commit_diff`] because it only reads the
191/// tree-level delta metadata.  The GUI uses this to instantly populate the
192/// file sidebar when a commit is selected.
193pub 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
218/// Return the diff for a **single file** within a commit.
219///
220/// Uses `pathspec` filtering so that git2 only walks the hunks / lines for the
221/// requested file — much faster than parsing the entire commit diff.
222pub 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
251/// Return the diff of a file between a specific commit and the current working directory.
252///
253/// This lets the user compare an old revision of a file with their current changes.
254/// If the file no longer exists in the working tree, shows the entire file as
255/// deleted (all lines removed). If the file is identical, returns an empty diff.
256pub 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    // Diff: commit tree → working directory (including the index)
272    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    // Empty diff — check WHY it's empty.
283    let in_commit = commit_tree
284        .get_path(std::path::Path::new(file_path))
285        .is_ok();
286
287    // Check if file exists in the working tree
288    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            // File exists in both — no changes (identical)
294            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            // File exists in commit but not in working tree — show as all-deleted
308            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            // File exists in working tree but not in commit — new file since commit
340            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
354/// Get the list of files that differ between a commit and the current working directory.
355pub 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
373// ── Helpers ───────────────────────────────────────────────────────────────────
374
375/// Extract lightweight `DiffFileEntry` values from a `git2::Diff` (paths + status only).
376fn 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
394/// Walk every delta / hunk / line in a `git2::Diff` and produce our domain
395/// `Vec<DiffInfo>`.
396fn 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    // Pre-populate DiffInfo shells for each delta so the print callback can
401    // index into them.
402    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    // Walk through the diff with the print callback which gives us
423    // file / hunk / line events in order.
424    let mut current_delta_idx: usize = 0;
425
426    diff.print(DiffFormat::Patch, |delta, maybe_hunk, line| {
427        // Identify which delta we are currently processing by matching paths.
428        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        // Find the matching DiffInfo — usually at current_delta_idx or later.
440        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                // Also search from the beginning in case deltas are reordered
446                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; // skip unknown delta
457        }
458
459        let info = &mut infos[current_delta_idx];
460
461        // If we have a hunk header, potentially create a new hunk.
462        if let Some(hunk) = maybe_hunk {
463            let header = String::from_utf8_lossy(hunk.header())
464                .trim_end()
465                .to_string();
466
467            // Only create a new hunk if the header differs from the current one.
468            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        // Map the line origin to our DiffLine type and append to the current hunk.
481        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                // File-level headers ('F'), binary notices ('B'), hunk header origin ('H')
492                // — we skip these as they are handled above or are informational.
493                _ => 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        // Modify the file
535        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        // Modify and stage the file
550        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        // Create a new untracked file
579        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        // Modify the file in the working directory
617        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        // Modify the file
631        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        // Add a new file and stage it
646        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        // Delete the committed file
665        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        // No changes
680        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        // Get the HEAD commit OID
692        let head_oid = repo
693            .head()
694            .unwrap()
695            .peel_to_commit()
696            .unwrap()
697            .id()
698            .to_string();
699        // Modify the file
700        std::fs::write(dir.path().join("hello.txt"), "modified content").unwrap();
701        // Restore using checkout_file_at_commit
702        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        // Range diff with the same commit as both ends should equal a single-commit diff
719        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
733    #[test]
734    fn working_dir_file_list_returns_modified_files() {
735        let tmp = tempfile::tempdir().unwrap();
736        let repo = init_repo_with_commit(tmp.path());
737
738        // Modify the file in the working directory
739        fs::write(tmp.path().join("hello.txt"), "Hello, modified!\n").unwrap();
740
741        let files = get_working_dir_file_list(&repo).unwrap();
742        assert_eq!(files.len(), 1);
743        assert_eq!(files[0].new_file, "hello.txt");
744    }
745
746    #[test]
747    fn working_dir_file_list_empty_for_clean_repo() {
748        let tmp = tempfile::tempdir().unwrap();
749        let repo = init_repo_with_commit(tmp.path());
750
751        // No modifications
752        let files = get_working_dir_file_list(&repo).unwrap();
753        assert!(
754            files.is_empty(),
755            "clean repo should have no working dir changes"
756        );
757    }
758
759    #[test]
760    fn staged_file_list_returns_staged_files() {
761        let tmp = tempfile::tempdir().unwrap();
762        let repo = init_repo_with_commit(tmp.path());
763
764        // Modify and stage the file
765        fs::write(tmp.path().join("hello.txt"), "Hello, staged!\n").unwrap();
766        let mut index = repo.index().unwrap();
767        index.add_path(std::path::Path::new("hello.txt")).unwrap();
768        index.write().unwrap();
769
770        let files = get_staged_file_list(&repo).unwrap();
771        assert_eq!(files.len(), 1);
772    }
773
774    #[test]
775    fn staged_file_list_empty_when_nothing_staged() {
776        let tmp = tempfile::tempdir().unwrap();
777        let repo = init_repo_with_commit(tmp.path());
778
779        // Modify the file but don't stage it
780        fs::write(tmp.path().join("hello.txt"), "Hello, unstaged!\n").unwrap();
781
782        let files = get_staged_file_list(&repo).unwrap();
783        assert!(
784            files.is_empty(),
785            "unstaged changes should not appear in staged file list"
786        );
787    }
788
789    #[test]
790    fn working_dir_single_file_diff_returns_diff_for_specific_file() {
791        let tmp = tempfile::tempdir().unwrap();
792        let repo = init_repo_with_commit(tmp.path());
793
794        // Add a second file and commit it
795        fs::write(tmp.path().join("other.txt"), "Other content\n").unwrap();
796        let mut index = repo.index().unwrap();
797        index.add_path(std::path::Path::new("other.txt")).unwrap();
798        index.write().unwrap();
799        let tree_oid = index.write_tree().unwrap();
800        let tree = repo.find_tree(tree_oid).unwrap();
801        let sig = git2::Signature::now("Test", "test@test.com").unwrap();
802        let parent = repo.head().unwrap().peel_to_commit().unwrap();
803        repo.commit(Some("HEAD"), &sig, &sig, "add other.txt", &tree, &[&parent])
804            .unwrap();
805
806        // Modify both files in the working directory
807        fs::write(tmp.path().join("hello.txt"), "Hello, changed!\n").unwrap();
808        fs::write(tmp.path().join("other.txt"), "Other changed!\n").unwrap();
809
810        let diff = get_working_dir_single_file_diff(&repo, "hello.txt").unwrap();
811        assert_eq!(diff.new_file, "hello.txt");
812        assert!(!diff.hunks.is_empty());
813    }
814
815    #[test]
816    fn staged_single_file_diff_returns_diff_for_staged_file() {
817        let tmp = tempfile::tempdir().unwrap();
818        let repo = init_repo_with_commit(tmp.path());
819
820        // Modify and stage the file
821        fs::write(tmp.path().join("hello.txt"), "Hello, staged diff!\n").unwrap();
822        let mut index = repo.index().unwrap();
823        index.add_path(std::path::Path::new("hello.txt")).unwrap();
824        index.write().unwrap();
825
826        let diff = get_staged_single_file_diff(&repo, "hello.txt").unwrap();
827        assert!(!diff.hunks.is_empty());
828    }
829}