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    /// Maximum number of diff lines to store per file.
401    /// Beyond this, additional lines are silently dropped to prevent
402    /// multi-GB memory consumption from huge generated/binary diffs.
403    const MAX_LINES_PER_FILE: usize = 10_000;
404
405    // Pre-populate DiffInfo shells for each delta so the print callback can
406    // index into them.
407    for delta in diff.deltas() {
408        let old_file = delta
409            .old_file()
410            .path()
411            .map(|p| p.to_string_lossy().into_owned())
412            .unwrap_or_default();
413        let new_file = delta
414            .new_file()
415            .path()
416            .map(|p| p.to_string_lossy().into_owned())
417            .unwrap_or_default();
418        let status = FileStatus::from_delta(delta.status());
419        infos.push(DiffInfo {
420            old_file,
421            new_file,
422            status,
423            hunks: Vec::new(),
424        });
425    }
426
427    // Walk through the diff with the print callback which gives us
428    // file / hunk / line events in order.
429    let mut current_delta_idx: usize = 0;
430    let mut current_line_count: usize = 0;
431
432    diff.print(DiffFormat::Patch, |delta, maybe_hunk, line| {
433        // Identify which delta we are currently processing by matching paths.
434        let delta_new = delta
435            .new_file()
436            .path()
437            .map(|p| p.to_string_lossy().into_owned())
438            .unwrap_or_default();
439        let delta_old = delta
440            .old_file()
441            .path()
442            .map(|p| p.to_string_lossy().into_owned())
443            .unwrap_or_default();
444
445        // Find the matching DiffInfo — usually at current_delta_idx or later.
446        let found_idx = infos[current_delta_idx..]
447            .iter()
448            .position(|info| info.new_file == delta_new && info.old_file == delta_old)
449            .map(|pos| pos + current_delta_idx)
450            .or_else(|| {
451                // Also search from the beginning in case deltas are reordered
452                infos[..current_delta_idx]
453                    .iter()
454                    .position(|info| info.new_file == delta_new && info.old_file == delta_old)
455            });
456
457        let found = found_idx.is_some();
458        if let Some(idx) = found_idx {
459            if idx != current_delta_idx {
460                // Switched to a new file — reset the line counter.
461                current_delta_idx = idx;
462                current_line_count = infos[idx].hunks.iter().map(|h| h.lines.len()).sum();
463            }
464        }
465        if !found {
466            return true; // skip unknown delta
467        }
468
469        // Skip lines beyond the cap to prevent unbounded memory growth.
470        if current_line_count >= MAX_LINES_PER_FILE {
471            return true;
472        }
473
474        let info = &mut infos[current_delta_idx];
475
476        // If we have a hunk header, potentially create a new hunk.
477        if let Some(hunk) = maybe_hunk {
478            let header = String::from_utf8_lossy(hunk.header())
479                .trim_end()
480                .to_string();
481
482            // Only create a new hunk if the header differs from the current one.
483            let needs_new = match info.hunks.last() {
484                Some(h) => h.header != header,
485                None => true,
486            };
487            if needs_new {
488                info.hunks.push(DiffHunk {
489                    header: header.clone(),
490                    lines: vec![DiffLine::HunkHeader(header)],
491                });
492                current_line_count += 1;
493            }
494        }
495
496        // Map the line origin to our DiffLine type and append to the current hunk.
497        if let Some(hunk) = info.hunks.last_mut() {
498            let content = String::from_utf8_lossy(line.content())
499                .trim_end_matches('\n')
500                .trim_end_matches('\r')
501                .to_string();
502
503            let diff_line = match line.origin() {
504                '+' | '>' => DiffLine::Addition(content),
505                '-' | '<' => DiffLine::Deletion(content),
506                ' ' => DiffLine::Context(content),
507                // File-level headers ('F'), binary notices ('B'), hunk header origin ('H')
508                // — we skip these as they are handled above or are informational.
509                _ => return true,
510            };
511            hunk.lines.push(diff_line);
512            current_line_count += 1;
513        }
514
515        true
516    })
517    .context("failed to walk diff")?;
518
519    Ok(infos)
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use std::fs;
526
527    fn init_repo_with_commit(dir: &std::path::Path) -> git2::Repository {
528        let repo = git2::Repository::init(dir).unwrap();
529        {
530            let file_path = dir.join("hello.txt");
531            fs::write(&file_path, "Hello, world!\n").unwrap();
532
533            let mut index = repo.index().unwrap();
534            index.add_path(std::path::Path::new("hello.txt")).unwrap();
535            index.write().unwrap();
536
537            let tree_oid = index.write_tree().unwrap();
538            let tree = repo.find_tree(tree_oid).unwrap();
539            let sig = git2::Signature::now("Test", "test@test.com").unwrap();
540            repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
541                .unwrap();
542        }
543        repo
544    }
545
546    #[test]
547    fn working_dir_diff_shows_changes() {
548        let tmp = tempfile::tempdir().unwrap();
549        let repo = init_repo_with_commit(tmp.path());
550
551        // Modify the file
552        fs::write(tmp.path().join("hello.txt"), "Hello, modified!\n").unwrap();
553
554        let diffs = get_working_dir_diff(&repo).unwrap();
555        assert_eq!(diffs.len(), 1);
556        assert_eq!(diffs[0].new_file, "hello.txt");
557        assert_eq!(diffs[0].status, FileStatus::Modified);
558        assert!(!diffs[0].hunks.is_empty());
559    }
560
561    #[test]
562    fn staged_diff_shows_staged_changes() {
563        let tmp = tempfile::tempdir().unwrap();
564        let repo = init_repo_with_commit(tmp.path());
565
566        // Modify and stage the file
567        fs::write(tmp.path().join("hello.txt"), "Hello, staged!\n").unwrap();
568        let mut index = repo.index().unwrap();
569        index.add_path(std::path::Path::new("hello.txt")).unwrap();
570        index.write().unwrap();
571
572        let diffs = get_staged_diff(&repo).unwrap();
573        assert_eq!(diffs.len(), 1);
574        assert_eq!(diffs[0].new_file, "hello.txt");
575        assert_eq!(diffs[0].status, FileStatus::Modified);
576    }
577
578    #[test]
579    fn commit_diff_shows_initial_commit() {
580        let tmp = tempfile::tempdir().unwrap();
581        let repo = init_repo_with_commit(tmp.path());
582
583        let head_oid = repo.head().unwrap().target().unwrap().to_string();
584        let diffs = get_commit_diff(&repo, &head_oid).unwrap();
585        assert_eq!(diffs.len(), 1);
586        assert_eq!(diffs[0].new_file, "hello.txt");
587        assert_eq!(diffs[0].status, FileStatus::New);
588    }
589
590    #[test]
591    fn working_dir_diff_untracked_file() {
592        let tmp = tempfile::tempdir().unwrap();
593        let repo = init_repo_with_commit(tmp.path());
594
595        // Create a new untracked file
596        fs::write(tmp.path().join("new_file.txt"), "I am new!\n").unwrap();
597
598        let diffs = get_working_dir_diff(&repo).unwrap();
599        assert_eq!(diffs.len(), 1);
600        assert_eq!(diffs[0].new_file, "new_file.txt");
601        assert_eq!(diffs[0].status, FileStatus::Untracked);
602    }
603
604    #[test]
605    fn commit_file_list_returns_entries() {
606        let tmp = tempfile::tempdir().unwrap();
607        let repo = init_repo_with_commit(tmp.path());
608        let head_oid = repo.head().unwrap().target().unwrap().to_string();
609        let files = get_commit_file_list(&repo, &head_oid).unwrap();
610        assert_eq!(files.len(), 1);
611        assert_eq!(files[0].new_file, "hello.txt");
612        assert_eq!(files[0].status, FileStatus::New);
613        assert_eq!(files[0].display_path(), "hello.txt");
614    }
615
616    #[test]
617    fn single_file_diff_returns_correct_file() {
618        let tmp = tempfile::tempdir().unwrap();
619        let repo = init_repo_with_commit(tmp.path());
620        let head_oid = repo.head().unwrap().target().unwrap().to_string();
621        let diff = get_single_file_diff(&repo, &head_oid, "hello.txt").unwrap();
622        assert_eq!(diff.new_file, "hello.txt");
623        assert_eq!(diff.status, FileStatus::New);
624        assert!(!diff.hunks.is_empty());
625    }
626
627    #[test]
628    fn diff_file_commit_vs_workdir_shows_changes() {
629        let tmp = tempfile::tempdir().unwrap();
630        let repo = init_repo_with_commit(tmp.path());
631        let head_oid = repo.head().unwrap().target().unwrap().to_string();
632
633        // Modify the file in the working directory
634        std::fs::write(tmp.path().join("hello.txt"), "Modified content!\n").unwrap();
635
636        let diff = diff_file_commit_vs_workdir(&repo, &head_oid, "hello.txt").unwrap();
637        assert_eq!(diff.new_file, "hello.txt");
638        assert!(!diff.hunks.is_empty());
639    }
640
641    #[test]
642    fn file_list_commit_vs_workdir_detects_modified() {
643        let tmp = tempfile::tempdir().unwrap();
644        let repo = init_repo_with_commit(tmp.path());
645        let head_oid = repo.head().unwrap().target().unwrap().to_string();
646
647        // Modify the file
648        std::fs::write(tmp.path().join("hello.txt"), "Changed!\n").unwrap();
649
650        let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
651        assert!(!files.is_empty());
652        assert_eq!(files[0].display_path(), "hello.txt");
653        assert_eq!(files[0].status, FileStatus::Modified);
654    }
655
656    #[test]
657    fn file_list_commit_vs_workdir_detects_new_file() {
658        let tmp = tempfile::tempdir().unwrap();
659        let repo = init_repo_with_commit(tmp.path());
660        let head_oid = repo.head().unwrap().target().unwrap().to_string();
661
662        // Add a new file and stage it
663        std::fs::write(tmp.path().join("new_file.txt"), "new\n").unwrap();
664        let mut index = repo.index().unwrap();
665        index
666            .add_path(std::path::Path::new("new_file.txt"))
667            .unwrap();
668        index.write().unwrap();
669
670        let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
671        let new = files.iter().find(|f| f.display_path() == "new_file.txt");
672        assert!(new.is_some(), "new_file.txt should appear in the diff list");
673    }
674
675    #[test]
676    fn file_list_commit_vs_workdir_detects_deletion() {
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
681        // Delete the committed file
682        std::fs::remove_file(tmp.path().join("hello.txt")).unwrap();
683
684        let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
685        let deleted = files.iter().find(|f| f.display_path() == "hello.txt");
686        assert!(deleted.is_some());
687        assert_eq!(deleted.unwrap().status, FileStatus::Deleted);
688    }
689
690    #[test]
691    fn file_list_commit_vs_workdir_empty_when_unchanged() {
692        let tmp = tempfile::tempdir().unwrap();
693        let repo = init_repo_with_commit(tmp.path());
694        let head_oid = repo.head().unwrap().target().unwrap().to_string();
695
696        // No changes
697        let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
698        assert!(
699            files.is_empty(),
700            "should be empty when working tree matches commit"
701        );
702    }
703
704    #[test]
705    fn checkout_file_at_commit_restores_file() {
706        let dir = tempfile::tempdir().unwrap();
707        let repo = init_repo_with_commit(dir.path());
708        // Get the HEAD commit OID
709        let head_oid = repo
710            .head()
711            .unwrap()
712            .peel_to_commit()
713            .unwrap()
714            .id()
715            .to_string();
716        // Modify the file
717        std::fs::write(dir.path().join("hello.txt"), "modified content").unwrap();
718        // Restore using checkout_file_at_commit
719        checkout_file_at_commit(&repo, &head_oid, "hello.txt").unwrap();
720        let content = std::fs::read_to_string(dir.path().join("hello.txt")).unwrap();
721        assert_eq!(content, "Hello, world!\n");
722    }
723
724    #[test]
725    fn range_diff_single_commit_matches_commit_diff() {
726        let dir = tempfile::tempdir().unwrap();
727        let repo = init_repo_with_commit(dir.path());
728        let oid = repo
729            .head()
730            .unwrap()
731            .peel_to_commit()
732            .unwrap()
733            .id()
734            .to_string();
735        // Range diff with the same commit as both ends should equal a single-commit diff
736        let range = get_commit_range_diff(&repo, &oid, &oid).unwrap();
737        let single = get_commit_diff(&repo, &oid).unwrap();
738        assert_eq!(range.len(), single.len());
739    }
740
741    #[test]
742    fn single_file_diff_not_found() {
743        let tmp = tempfile::tempdir().unwrap();
744        let repo = init_repo_with_commit(tmp.path());
745        let head_oid = repo.head().unwrap().target().unwrap().to_string();
746        let result = get_single_file_diff(&repo, &head_oid, "nonexistent.txt");
747        assert!(result.is_err());
748    }
749
750    #[test]
751    fn working_dir_file_list_returns_modified_files() {
752        let tmp = tempfile::tempdir().unwrap();
753        let repo = init_repo_with_commit(tmp.path());
754
755        // Modify the file in the working directory
756        fs::write(tmp.path().join("hello.txt"), "Hello, modified!\n").unwrap();
757
758        let files = get_working_dir_file_list(&repo).unwrap();
759        assert_eq!(files.len(), 1);
760        assert_eq!(files[0].new_file, "hello.txt");
761    }
762
763    #[test]
764    fn working_dir_file_list_empty_for_clean_repo() {
765        let tmp = tempfile::tempdir().unwrap();
766        let repo = init_repo_with_commit(tmp.path());
767
768        // No modifications
769        let files = get_working_dir_file_list(&repo).unwrap();
770        assert!(
771            files.is_empty(),
772            "clean repo should have no working dir changes"
773        );
774    }
775
776    #[test]
777    fn staged_file_list_returns_staged_files() {
778        let tmp = tempfile::tempdir().unwrap();
779        let repo = init_repo_with_commit(tmp.path());
780
781        // Modify and stage the file
782        fs::write(tmp.path().join("hello.txt"), "Hello, staged!\n").unwrap();
783        let mut index = repo.index().unwrap();
784        index.add_path(std::path::Path::new("hello.txt")).unwrap();
785        index.write().unwrap();
786
787        let files = get_staged_file_list(&repo).unwrap();
788        assert_eq!(files.len(), 1);
789    }
790
791    #[test]
792    fn staged_file_list_empty_when_nothing_staged() {
793        let tmp = tempfile::tempdir().unwrap();
794        let repo = init_repo_with_commit(tmp.path());
795
796        // Modify the file but don't stage it
797        fs::write(tmp.path().join("hello.txt"), "Hello, unstaged!\n").unwrap();
798
799        let files = get_staged_file_list(&repo).unwrap();
800        assert!(
801            files.is_empty(),
802            "unstaged changes should not appear in staged file list"
803        );
804    }
805
806    #[test]
807    fn working_dir_single_file_diff_returns_diff_for_specific_file() {
808        let tmp = tempfile::tempdir().unwrap();
809        let repo = init_repo_with_commit(tmp.path());
810
811        // Add a second file and commit it
812        fs::write(tmp.path().join("other.txt"), "Other content\n").unwrap();
813        let mut index = repo.index().unwrap();
814        index.add_path(std::path::Path::new("other.txt")).unwrap();
815        index.write().unwrap();
816        let tree_oid = index.write_tree().unwrap();
817        let tree = repo.find_tree(tree_oid).unwrap();
818        let sig = git2::Signature::now("Test", "test@test.com").unwrap();
819        let parent = repo.head().unwrap().peel_to_commit().unwrap();
820        repo.commit(Some("HEAD"), &sig, &sig, "add other.txt", &tree, &[&parent])
821            .unwrap();
822
823        // Modify both files in the working directory
824        fs::write(tmp.path().join("hello.txt"), "Hello, changed!\n").unwrap();
825        fs::write(tmp.path().join("other.txt"), "Other changed!\n").unwrap();
826
827        let diff = get_working_dir_single_file_diff(&repo, "hello.txt").unwrap();
828        assert_eq!(diff.new_file, "hello.txt");
829        assert!(!diff.hunks.is_empty());
830    }
831
832    #[test]
833    fn staged_single_file_diff_returns_diff_for_staged_file() {
834        let tmp = tempfile::tempdir().unwrap();
835        let repo = init_repo_with_commit(tmp.path());
836
837        // Modify and stage the file
838        fs::write(tmp.path().join("hello.txt"), "Hello, staged diff!\n").unwrap();
839        let mut index = repo.index().unwrap();
840        index.add_path(std::path::Path::new("hello.txt")).unwrap();
841        index.write().unwrap();
842
843        let diff = get_staged_single_file_diff(&repo, "hello.txt").unwrap();
844        assert!(!diff.hunks.is_empty());
845    }
846}