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.
151pub fn diff_file_commit_vs_workdir(
152    repo: &Repository,
153    oid_str: &str,
154    file_path: &str,
155) -> Result<DiffInfo> {
156    let oid =
157        git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
158    let commit = repo
159        .find_commit(oid)
160        .with_context(|| format!("commit {oid_str} not found"))?;
161    let commit_tree = commit.tree().context("commit has no tree")?;
162
163    let mut opts = DiffOptions::new();
164    opts.pathspec(file_path);
165
166    // Diff: commit tree → working directory (skipping the index)
167    let diff = repo
168        .diff_tree_to_workdir_with_index(Some(&commit_tree), Some(&mut opts))
169        .context("failed to diff commit tree against working directory")?;
170
171    let infos = parse_diff(&diff)?;
172    infos
173        .into_iter()
174        .next()
175        .ok_or_else(|| anyhow::anyhow!("file '{}' not found in diff", file_path))
176}
177
178// ── Helpers ───────────────────────────────────────────────────────────────────
179
180/// Walk every delta / hunk / line in a `git2::Diff` and produce our domain
181/// `Vec<DiffInfo>`.
182fn parse_diff(diff: &Diff<'_>) -> Result<Vec<DiffInfo>> {
183    let num_deltas = diff.deltas().len();
184    let mut infos: Vec<DiffInfo> = Vec::with_capacity(num_deltas);
185
186    // Pre-populate DiffInfo shells for each delta so the print callback can
187    // index into them.
188    for delta in diff.deltas() {
189        let old_file = delta
190            .old_file()
191            .path()
192            .map(|p| p.to_string_lossy().into_owned())
193            .unwrap_or_default();
194        let new_file = delta
195            .new_file()
196            .path()
197            .map(|p| p.to_string_lossy().into_owned())
198            .unwrap_or_default();
199        let status = FileStatus::from_delta(delta.status());
200        infos.push(DiffInfo {
201            old_file,
202            new_file,
203            status,
204            hunks: Vec::new(),
205        });
206    }
207
208    // Walk through the diff with the print callback which gives us
209    // file / hunk / line events in order.
210    let mut current_delta_idx: usize = 0;
211
212    diff.print(DiffFormat::Patch, |delta, maybe_hunk, line| {
213        // Identify which delta we are currently processing by matching paths.
214        let delta_new = delta
215            .new_file()
216            .path()
217            .map(|p| p.to_string_lossy().into_owned())
218            .unwrap_or_default();
219        let delta_old = delta
220            .old_file()
221            .path()
222            .map(|p| p.to_string_lossy().into_owned())
223            .unwrap_or_default();
224
225        // Find the matching DiffInfo — usually at current_delta_idx or later.
226        let found_idx = infos[current_delta_idx..]
227            .iter()
228            .position(|info| info.new_file == delta_new && info.old_file == delta_old)
229            .map(|pos| pos + current_delta_idx)
230            .or_else(|| {
231                // Also search from the beginning in case deltas are reordered
232                infos[..current_delta_idx]
233                    .iter()
234                    .position(|info| info.new_file == delta_new && info.old_file == delta_old)
235            });
236
237        let found = found_idx.is_some();
238        if let Some(idx) = found_idx {
239            current_delta_idx = idx;
240        }
241        if !found {
242            return true; // skip unknown delta
243        }
244
245        let info = &mut infos[current_delta_idx];
246
247        // If we have a hunk header, potentially create a new hunk.
248        if let Some(hunk) = maybe_hunk {
249            let header = String::from_utf8_lossy(hunk.header())
250                .trim_end()
251                .to_string();
252
253            // Only create a new hunk if the header differs from the current one.
254            let needs_new = match info.hunks.last() {
255                Some(h) => h.header != header,
256                None => true,
257            };
258            if needs_new {
259                info.hunks.push(DiffHunk {
260                    header: header.clone(),
261                    lines: vec![DiffLine::HunkHeader(header)],
262                });
263            }
264        }
265
266        // Map the line origin to our DiffLine type and append to the current hunk.
267        if let Some(hunk) = info.hunks.last_mut() {
268            let content = String::from_utf8_lossy(line.content())
269                .trim_end_matches('\n')
270                .trim_end_matches('\r')
271                .to_string();
272
273            let diff_line = match line.origin() {
274                '+' | '>' => DiffLine::Addition(content),
275                '-' | '<' => DiffLine::Deletion(content),
276                ' ' => DiffLine::Context(content),
277                // File-level headers ('F'), binary notices ('B'), hunk header origin ('H')
278                // — we skip these as they are handled above or are informational.
279                _ => return true,
280            };
281            hunk.lines.push(diff_line);
282        }
283
284        true
285    })
286    .context("failed to walk diff")?;
287
288    Ok(infos)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use std::fs;
295
296    fn init_repo_with_commit(dir: &std::path::Path) -> git2::Repository {
297        let repo = git2::Repository::init(dir).unwrap();
298        {
299            let file_path = dir.join("hello.txt");
300            fs::write(&file_path, "Hello, world!\n").unwrap();
301
302            let mut index = repo.index().unwrap();
303            index.add_path(std::path::Path::new("hello.txt")).unwrap();
304            index.write().unwrap();
305
306            let tree_oid = index.write_tree().unwrap();
307            let tree = repo.find_tree(tree_oid).unwrap();
308            let sig = git2::Signature::now("Test", "test@test.com").unwrap();
309            repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
310                .unwrap();
311        }
312        repo
313    }
314
315    #[test]
316    fn working_dir_diff_shows_changes() {
317        let tmp = tempfile::tempdir().unwrap();
318        let repo = init_repo_with_commit(tmp.path());
319
320        // Modify the file
321        fs::write(tmp.path().join("hello.txt"), "Hello, modified!\n").unwrap();
322
323        let diffs = get_working_dir_diff(&repo).unwrap();
324        assert_eq!(diffs.len(), 1);
325        assert_eq!(diffs[0].new_file, "hello.txt");
326        assert_eq!(diffs[0].status, FileStatus::Modified);
327        assert!(!diffs[0].hunks.is_empty());
328    }
329
330    #[test]
331    fn staged_diff_shows_staged_changes() {
332        let tmp = tempfile::tempdir().unwrap();
333        let repo = init_repo_with_commit(tmp.path());
334
335        // Modify and stage the file
336        fs::write(tmp.path().join("hello.txt"), "Hello, staged!\n").unwrap();
337        let mut index = repo.index().unwrap();
338        index.add_path(std::path::Path::new("hello.txt")).unwrap();
339        index.write().unwrap();
340
341        let diffs = get_staged_diff(&repo).unwrap();
342        assert_eq!(diffs.len(), 1);
343        assert_eq!(diffs[0].new_file, "hello.txt");
344        assert_eq!(diffs[0].status, FileStatus::Modified);
345    }
346
347    #[test]
348    fn commit_diff_shows_initial_commit() {
349        let tmp = tempfile::tempdir().unwrap();
350        let repo = init_repo_with_commit(tmp.path());
351
352        let head_oid = repo.head().unwrap().target().unwrap().to_string();
353        let diffs = get_commit_diff(&repo, &head_oid).unwrap();
354        assert_eq!(diffs.len(), 1);
355        assert_eq!(diffs[0].new_file, "hello.txt");
356        assert_eq!(diffs[0].status, FileStatus::New);
357    }
358
359    #[test]
360    fn working_dir_diff_untracked_file() {
361        let tmp = tempfile::tempdir().unwrap();
362        let repo = init_repo_with_commit(tmp.path());
363
364        // Create a new untracked file
365        fs::write(tmp.path().join("new_file.txt"), "I am new!\n").unwrap();
366
367        let diffs = get_working_dir_diff(&repo).unwrap();
368        assert_eq!(diffs.len(), 1);
369        assert_eq!(diffs[0].new_file, "new_file.txt");
370        assert_eq!(diffs[0].status, FileStatus::Untracked);
371    }
372
373    #[test]
374    fn commit_file_list_returns_entries() {
375        let tmp = tempfile::tempdir().unwrap();
376        let repo = init_repo_with_commit(tmp.path());
377        let head_oid = repo.head().unwrap().target().unwrap().to_string();
378        let files = get_commit_file_list(&repo, &head_oid).unwrap();
379        assert_eq!(files.len(), 1);
380        assert_eq!(files[0].new_file, "hello.txt");
381        assert_eq!(files[0].status, FileStatus::New);
382        assert_eq!(files[0].display_path(), "hello.txt");
383    }
384
385    #[test]
386    fn single_file_diff_returns_correct_file() {
387        let tmp = tempfile::tempdir().unwrap();
388        let repo = init_repo_with_commit(tmp.path());
389        let head_oid = repo.head().unwrap().target().unwrap().to_string();
390        let diff = get_single_file_diff(&repo, &head_oid, "hello.txt").unwrap();
391        assert_eq!(diff.new_file, "hello.txt");
392        assert_eq!(diff.status, FileStatus::New);
393        assert!(!diff.hunks.is_empty());
394    }
395
396    #[test]
397    fn diff_file_commit_vs_workdir_shows_changes() {
398        let tmp = tempfile::tempdir().unwrap();
399        let repo = init_repo_with_commit(tmp.path());
400        let head_oid = repo.head().unwrap().target().unwrap().to_string();
401
402        // Modify the file in the working directory
403        std::fs::write(tmp.path().join("hello.txt"), "Modified content!\n").unwrap();
404
405        let diff = diff_file_commit_vs_workdir(&repo, &head_oid, "hello.txt").unwrap();
406        assert_eq!(diff.new_file, "hello.txt");
407        assert!(!diff.hunks.is_empty());
408    }
409
410    #[test]
411    fn single_file_diff_not_found() {
412        let tmp = tempfile::tempdir().unwrap();
413        let repo = init_repo_with_commit(tmp.path());
414        let head_oid = repo.head().unwrap().target().unwrap().to_string();
415        let result = get_single_file_diff(&repo, &head_oid, "nonexistent.txt");
416        assert!(result.is_err());
417    }
418}