gitkraft_core/features/diff/
ops.rs1use anyhow::{Context, Result};
4use git2::{Diff, DiffFormat, DiffOptions, Repository};
5
6use super::types::{DiffHunk, DiffInfo, DiffLine, FileStatus};
7
8pub 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
24pub 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 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
45pub 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
70pub 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
115pub 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
148pub 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 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 let in_commit = commit_tree
181 .get_path(std::path::Path::new(file_path))
182 .is_ok();
183
184 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 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 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 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
251pub 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
285fn 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 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 let mut current_delta_idx: usize = 0;
318
319 diff.print(DiffFormat::Patch, |delta, maybe_hunk, line| {
320 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 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 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; }
351
352 let info = &mut infos[current_delta_idx];
353
354 if let Some(hunk) = maybe_hunk {
356 let header = String::from_utf8_lossy(hunk.header())
357 .trim_end()
358 .to_string();
359
360 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 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 _ => 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 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 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 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 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 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 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 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 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}