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 just the list of changed files for a commit — no hunk / line parsing.
71///
72/// This is much faster than [`get_commit_diff`] because it only reads the
73/// tree-level delta metadata.  The GUI uses this to instantly populate the
74/// file sidebar when a commit is selected.
75pub fn get_commit_file_list(
76    repo: &Repository,
77    oid_str: &str,
78) -> Result<Vec<super::types::DiffFileEntry>> {
79    let oid =
80        git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
81    let commit = repo
82        .find_commit(oid)
83        .with_context(|| format!("commit {oid_str} not found"))?;
84    let commit_tree = commit.tree().context("commit has no tree")?;
85
86    let parent_tree = if commit.parent_count() > 0 {
87        let parent = commit.parent(0).context("failed to read parent commit")?;
88        Some(parent.tree().context("parent commit has no tree")?)
89    } else {
90        None
91    };
92
93    let diff = repo
94        .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)
95        .context("failed to diff commit against parent")?;
96
97    Ok(diff
98        .deltas()
99        .map(|delta| super::types::DiffFileEntry {
100            old_file: delta
101                .old_file()
102                .path()
103                .map(|p| p.to_string_lossy().into_owned())
104                .unwrap_or_default(),
105            new_file: delta
106                .new_file()
107                .path()
108                .map(|p| p.to_string_lossy().into_owned())
109                .unwrap_or_default(),
110            status: FileStatus::from_delta(delta.status()),
111        })
112        .collect())
113}
114
115/// Return the diff for a **single file** within a commit.
116///
117/// Uses `pathspec` filtering so that git2 only walks the hunks / lines for the
118/// requested file — much faster than parsing the entire commit diff.
119pub fn get_single_file_diff(repo: &Repository, oid_str: &str, file_path: &str) -> Result<DiffInfo> {
120    let oid =
121        git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
122    let commit = repo
123        .find_commit(oid)
124        .with_context(|| format!("commit {oid_str} not found"))?;
125    let commit_tree = commit.tree().context("commit has no tree")?;
126
127    let parent_tree = if commit.parent_count() > 0 {
128        let parent = commit.parent(0).context("failed to read parent commit")?;
129        Some(parent.tree().context("parent commit has no tree")?)
130    } else {
131        None
132    };
133
134    let mut opts = DiffOptions::new();
135    opts.pathspec(file_path);
136
137    let diff = repo
138        .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts))
139        .context("failed to diff commit against parent for single file")?;
140
141    let infos = parse_diff(&diff)?;
142    infos
143        .into_iter()
144        .next()
145        .ok_or_else(|| anyhow::anyhow!("file '{}' not found in commit diff", file_path))
146}
147
148/// Return the diff of a file between a specific commit and the current working directory.
149///
150/// This lets the user compare an old revision of a file with their current changes.
151/// If the file no longer exists in the working tree, shows the entire file as
152/// deleted (all lines removed). If the file is identical, returns an empty diff.
153pub fn diff_file_commit_vs_workdir(
154    repo: &Repository,
155    oid_str: &str,
156    file_path: &str,
157) -> Result<DiffInfo> {
158    let oid =
159        git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
160    let commit = repo
161        .find_commit(oid)
162        .with_context(|| format!("commit {oid_str} not found"))?;
163    let commit_tree = commit.tree().context("commit has no tree")?;
164
165    let mut opts = DiffOptions::new();
166    opts.pathspec(file_path);
167
168    // Diff: commit tree → working directory (including the index)
169    let diff = repo
170        .diff_tree_to_workdir_with_index(Some(&commit_tree), Some(&mut opts))
171        .context("failed to diff commit tree against working directory")?;
172
173    let infos = parse_diff(&diff)?;
174
175    if let Some(info) = infos.into_iter().next() {
176        return Ok(info);
177    }
178
179    // Empty diff — check WHY it's empty.
180    let in_commit = commit_tree
181        .get_path(std::path::Path::new(file_path))
182        .is_ok();
183
184    // Check if file exists in the working tree
185    let workdir = repo.workdir().context("bare repository")?;
186    let in_workdir = workdir.join(file_path).exists();
187
188    match (in_commit, in_workdir) {
189        (true, true) => {
190            // File exists in both — no changes (identical)
191            Ok(DiffInfo {
192                old_file: file_path.to_string(),
193                new_file: file_path.to_string(),
194                status: FileStatus::Modified,
195                hunks: vec![DiffHunk {
196                    header: "@@ No changes — file is identical @@".to_string(),
197                    lines: vec![DiffLine::HunkHeader(
198                        "@@ No changes — file is identical to working tree @@".to_string(),
199                    )],
200                }],
201            })
202        }
203        (true, false) => {
204            // File exists in commit but not in working tree — show as all-deleted
205            let blob_entry = commit_tree.get_path(std::path::Path::new(file_path))?;
206            let mut hunks = Vec::new();
207            if let Ok(blob) = repo.find_blob(blob_entry.id()) {
208                let content = String::from_utf8_lossy(blob.content());
209                let lines: Vec<DiffLine> = std::iter::once(DiffLine::HunkHeader(format!(
210                    "@@ File deleted since commit {} @@",
211                    &oid_str[..7.min(oid_str.len())]
212                )))
213                .chain(content.lines().map(|l| DiffLine::Deletion(l.to_string())))
214                .collect();
215
216                hunks.push(DiffHunk {
217                    header: lines
218                        .first()
219                        .map(|l| match l {
220                            DiffLine::HunkHeader(h) => h.clone(),
221                            _ => String::new(),
222                        })
223                        .unwrap_or_default(),
224                    lines,
225                });
226            }
227
228            Ok(DiffInfo {
229                old_file: file_path.to_string(),
230                new_file: String::new(),
231                status: FileStatus::Deleted,
232                hunks,
233            })
234        }
235        (false, true) => {
236            // File exists in working tree but not in commit — new file since commit
237            Err(anyhow::anyhow!(
238                "file '{}' did not exist at commit {} — it was added later",
239                file_path,
240                &oid_str[..7.min(oid_str.len())]
241            ))
242        }
243        (false, false) => Err(anyhow::anyhow!(
244            "file '{}' not found in commit {} or working tree — it may have been renamed",
245            file_path,
246            &oid_str[..7.min(oid_str.len())]
247        )),
248    }
249}
250
251/// Get the list of files that differ between a commit and the current working directory.
252pub fn file_list_commit_vs_workdir(
253    repo: &Repository,
254    oid_str: &str,
255) -> Result<Vec<super::types::DiffFileEntry>> {
256    let oid =
257        git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
258    let commit = repo
259        .find_commit(oid)
260        .with_context(|| format!("commit {oid_str} not found"))?;
261    let commit_tree = commit.tree().context("commit has no tree")?;
262
263    let diff = repo
264        .diff_tree_to_workdir_with_index(Some(&commit_tree), None)
265        .context("failed to diff commit tree against working directory")?;
266
267    Ok(diff
268        .deltas()
269        .map(|delta| super::types::DiffFileEntry {
270            old_file: delta
271                .old_file()
272                .path()
273                .map(|p| p.to_string_lossy().into_owned())
274                .unwrap_or_default(),
275            new_file: delta
276                .new_file()
277                .path()
278                .map(|p| p.to_string_lossy().into_owned())
279                .unwrap_or_default(),
280            status: FileStatus::from_delta(delta.status()),
281        })
282        .collect())
283}
284
285// ── Helpers ───────────────────────────────────────────────────────────────────
286
287/// Walk every delta / hunk / line in a `git2::Diff` and produce our domain
288/// `Vec<DiffInfo>`.
289fn parse_diff(diff: &Diff<'_>) -> Result<Vec<DiffInfo>> {
290    let num_deltas = diff.deltas().len();
291    let mut infos: Vec<DiffInfo> = Vec::with_capacity(num_deltas);
292
293    // Pre-populate DiffInfo shells for each delta so the print callback can
294    // index into them.
295    for delta in diff.deltas() {
296        let old_file = delta
297            .old_file()
298            .path()
299            .map(|p| p.to_string_lossy().into_owned())
300            .unwrap_or_default();
301        let new_file = delta
302            .new_file()
303            .path()
304            .map(|p| p.to_string_lossy().into_owned())
305            .unwrap_or_default();
306        let status = FileStatus::from_delta(delta.status());
307        infos.push(DiffInfo {
308            old_file,
309            new_file,
310            status,
311            hunks: Vec::new(),
312        });
313    }
314
315    // Walk through the diff with the print callback which gives us
316    // file / hunk / line events in order.
317    let mut current_delta_idx: usize = 0;
318
319    diff.print(DiffFormat::Patch, |delta, maybe_hunk, line| {
320        // Identify which delta we are currently processing by matching paths.
321        let delta_new = delta
322            .new_file()
323            .path()
324            .map(|p| p.to_string_lossy().into_owned())
325            .unwrap_or_default();
326        let delta_old = delta
327            .old_file()
328            .path()
329            .map(|p| p.to_string_lossy().into_owned())
330            .unwrap_or_default();
331
332        // Find the matching DiffInfo — usually at current_delta_idx or later.
333        let found_idx = infos[current_delta_idx..]
334            .iter()
335            .position(|info| info.new_file == delta_new && info.old_file == delta_old)
336            .map(|pos| pos + current_delta_idx)
337            .or_else(|| {
338                // Also search from the beginning in case deltas are reordered
339                infos[..current_delta_idx]
340                    .iter()
341                    .position(|info| info.new_file == delta_new && info.old_file == delta_old)
342            });
343
344        let found = found_idx.is_some();
345        if let Some(idx) = found_idx {
346            current_delta_idx = idx;
347        }
348        if !found {
349            return true; // skip unknown delta
350        }
351
352        let info = &mut infos[current_delta_idx];
353
354        // If we have a hunk header, potentially create a new hunk.
355        if let Some(hunk) = maybe_hunk {
356            let header = String::from_utf8_lossy(hunk.header())
357                .trim_end()
358                .to_string();
359
360            // Only create a new hunk if the header differs from the current one.
361            let needs_new = match info.hunks.last() {
362                Some(h) => h.header != header,
363                None => true,
364            };
365            if needs_new {
366                info.hunks.push(DiffHunk {
367                    header: header.clone(),
368                    lines: vec![DiffLine::HunkHeader(header)],
369                });
370            }
371        }
372
373        // Map the line origin to our DiffLine type and append to the current hunk.
374        if let Some(hunk) = info.hunks.last_mut() {
375            let content = String::from_utf8_lossy(line.content())
376                .trim_end_matches('\n')
377                .trim_end_matches('\r')
378                .to_string();
379
380            let diff_line = match line.origin() {
381                '+' | '>' => DiffLine::Addition(content),
382                '-' | '<' => DiffLine::Deletion(content),
383                ' ' => DiffLine::Context(content),
384                // File-level headers ('F'), binary notices ('B'), hunk header origin ('H')
385                // — we skip these as they are handled above or are informational.
386                _ => return true,
387            };
388            hunk.lines.push(diff_line);
389        }
390
391        true
392    })
393    .context("failed to walk diff")?;
394
395    Ok(infos)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use std::fs;
402
403    fn init_repo_with_commit(dir: &std::path::Path) -> git2::Repository {
404        let repo = git2::Repository::init(dir).unwrap();
405        {
406            let file_path = dir.join("hello.txt");
407            fs::write(&file_path, "Hello, world!\n").unwrap();
408
409            let mut index = repo.index().unwrap();
410            index.add_path(std::path::Path::new("hello.txt")).unwrap();
411            index.write().unwrap();
412
413            let tree_oid = index.write_tree().unwrap();
414            let tree = repo.find_tree(tree_oid).unwrap();
415            let sig = git2::Signature::now("Test", "test@test.com").unwrap();
416            repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
417                .unwrap();
418        }
419        repo
420    }
421
422    #[test]
423    fn working_dir_diff_shows_changes() {
424        let tmp = tempfile::tempdir().unwrap();
425        let repo = init_repo_with_commit(tmp.path());
426
427        // Modify the file
428        fs::write(tmp.path().join("hello.txt"), "Hello, modified!\n").unwrap();
429
430        let diffs = get_working_dir_diff(&repo).unwrap();
431        assert_eq!(diffs.len(), 1);
432        assert_eq!(diffs[0].new_file, "hello.txt");
433        assert_eq!(diffs[0].status, FileStatus::Modified);
434        assert!(!diffs[0].hunks.is_empty());
435    }
436
437    #[test]
438    fn staged_diff_shows_staged_changes() {
439        let tmp = tempfile::tempdir().unwrap();
440        let repo = init_repo_with_commit(tmp.path());
441
442        // Modify and stage the file
443        fs::write(tmp.path().join("hello.txt"), "Hello, staged!\n").unwrap();
444        let mut index = repo.index().unwrap();
445        index.add_path(std::path::Path::new("hello.txt")).unwrap();
446        index.write().unwrap();
447
448        let diffs = get_staged_diff(&repo).unwrap();
449        assert_eq!(diffs.len(), 1);
450        assert_eq!(diffs[0].new_file, "hello.txt");
451        assert_eq!(diffs[0].status, FileStatus::Modified);
452    }
453
454    #[test]
455    fn commit_diff_shows_initial_commit() {
456        let tmp = tempfile::tempdir().unwrap();
457        let repo = init_repo_with_commit(tmp.path());
458
459        let head_oid = repo.head().unwrap().target().unwrap().to_string();
460        let diffs = get_commit_diff(&repo, &head_oid).unwrap();
461        assert_eq!(diffs.len(), 1);
462        assert_eq!(diffs[0].new_file, "hello.txt");
463        assert_eq!(diffs[0].status, FileStatus::New);
464    }
465
466    #[test]
467    fn working_dir_diff_untracked_file() {
468        let tmp = tempfile::tempdir().unwrap();
469        let repo = init_repo_with_commit(tmp.path());
470
471        // Create a new untracked file
472        fs::write(tmp.path().join("new_file.txt"), "I am new!\n").unwrap();
473
474        let diffs = get_working_dir_diff(&repo).unwrap();
475        assert_eq!(diffs.len(), 1);
476        assert_eq!(diffs[0].new_file, "new_file.txt");
477        assert_eq!(diffs[0].status, FileStatus::Untracked);
478    }
479
480    #[test]
481    fn commit_file_list_returns_entries() {
482        let tmp = tempfile::tempdir().unwrap();
483        let repo = init_repo_with_commit(tmp.path());
484        let head_oid = repo.head().unwrap().target().unwrap().to_string();
485        let files = get_commit_file_list(&repo, &head_oid).unwrap();
486        assert_eq!(files.len(), 1);
487        assert_eq!(files[0].new_file, "hello.txt");
488        assert_eq!(files[0].status, FileStatus::New);
489        assert_eq!(files[0].display_path(), "hello.txt");
490    }
491
492    #[test]
493    fn single_file_diff_returns_correct_file() {
494        let tmp = tempfile::tempdir().unwrap();
495        let repo = init_repo_with_commit(tmp.path());
496        let head_oid = repo.head().unwrap().target().unwrap().to_string();
497        let diff = get_single_file_diff(&repo, &head_oid, "hello.txt").unwrap();
498        assert_eq!(diff.new_file, "hello.txt");
499        assert_eq!(diff.status, FileStatus::New);
500        assert!(!diff.hunks.is_empty());
501    }
502
503    #[test]
504    fn diff_file_commit_vs_workdir_shows_changes() {
505        let tmp = tempfile::tempdir().unwrap();
506        let repo = init_repo_with_commit(tmp.path());
507        let head_oid = repo.head().unwrap().target().unwrap().to_string();
508
509        // Modify the file in the working directory
510        std::fs::write(tmp.path().join("hello.txt"), "Modified content!\n").unwrap();
511
512        let diff = diff_file_commit_vs_workdir(&repo, &head_oid, "hello.txt").unwrap();
513        assert_eq!(diff.new_file, "hello.txt");
514        assert!(!diff.hunks.is_empty());
515    }
516
517    #[test]
518    fn file_list_commit_vs_workdir_detects_modified() {
519        let tmp = tempfile::tempdir().unwrap();
520        let repo = init_repo_with_commit(tmp.path());
521        let head_oid = repo.head().unwrap().target().unwrap().to_string();
522
523        // Modify the file
524        std::fs::write(tmp.path().join("hello.txt"), "Changed!\n").unwrap();
525
526        let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
527        assert!(!files.is_empty());
528        assert_eq!(files[0].display_path(), "hello.txt");
529        assert_eq!(files[0].status, FileStatus::Modified);
530    }
531
532    #[test]
533    fn file_list_commit_vs_workdir_detects_new_file() {
534        let tmp = tempfile::tempdir().unwrap();
535        let repo = init_repo_with_commit(tmp.path());
536        let head_oid = repo.head().unwrap().target().unwrap().to_string();
537
538        // Add a new file and stage it
539        std::fs::write(tmp.path().join("new_file.txt"), "new\n").unwrap();
540        let mut index = repo.index().unwrap();
541        index
542            .add_path(std::path::Path::new("new_file.txt"))
543            .unwrap();
544        index.write().unwrap();
545
546        let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
547        let new = files.iter().find(|f| f.display_path() == "new_file.txt");
548        assert!(new.is_some(), "new_file.txt should appear in the diff list");
549    }
550
551    #[test]
552    fn file_list_commit_vs_workdir_detects_deletion() {
553        let tmp = tempfile::tempdir().unwrap();
554        let repo = init_repo_with_commit(tmp.path());
555        let head_oid = repo.head().unwrap().target().unwrap().to_string();
556
557        // Delete the committed file
558        std::fs::remove_file(tmp.path().join("hello.txt")).unwrap();
559
560        let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
561        let deleted = files.iter().find(|f| f.display_path() == "hello.txt");
562        assert!(deleted.is_some());
563        assert_eq!(deleted.unwrap().status, FileStatus::Deleted);
564    }
565
566    #[test]
567    fn file_list_commit_vs_workdir_empty_when_unchanged() {
568        let tmp = tempfile::tempdir().unwrap();
569        let repo = init_repo_with_commit(tmp.path());
570        let head_oid = repo.head().unwrap().target().unwrap().to_string();
571
572        // No changes
573        let files = file_list_commit_vs_workdir(&repo, &head_oid).unwrap();
574        assert!(
575            files.is_empty(),
576            "should be empty when working tree matches commit"
577        );
578    }
579
580    #[test]
581    fn single_file_diff_not_found() {
582        let tmp = tempfile::tempdir().unwrap();
583        let repo = init_repo_with_commit(tmp.path());
584        let head_oid = repo.head().unwrap().target().unwrap().to_string();
585        let result = get_single_file_diff(&repo, &head_oid, "nonexistent.txt");
586        assert!(result.is_err());
587    }
588}