Skip to main content

changeset_git/repository/
diff.rs

1use std::path::PathBuf;
2
3use crate::{FileChange, FileStatus, GitError, Result};
4
5use super::Repository;
6
7impl Repository {
8    /// # Errors
9    ///
10    /// Returns [`GitError::RefNotFound`] if either base or head cannot be resolved.
11    pub fn changed_files(&self, base: Option<&str>, head: &str) -> Result<Vec<FileChange>> {
12        let head_tree = self.resolve_tree(head)?;
13
14        let base_tree = base.map(|refspec| self.resolve_tree(refspec)).transpose()?;
15
16        let mut diff = self
17            .inner
18            .diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
19
20        let mut find_opts = git2::DiffFindOptions::new();
21        find_opts.renames(true);
22        find_opts.copies(true);
23        find_opts.copies_from_unmodified(true);
24        diff.find_similar(Some(&mut find_opts))?;
25
26        collect_changes(&diff)
27    }
28
29    /// # Errors
30    ///
31    /// Returns [`GitError::RefNotFound`] if HEAD cannot be resolved.
32    pub fn uncommitted_changes(&self) -> Result<Vec<FileChange>> {
33        let head_tree = self.resolve_tree("HEAD")?;
34
35        let mut diff_opts = git2::DiffOptions::new();
36        diff_opts.include_untracked(true);
37        diff_opts.recurse_untracked_dirs(true);
38
39        let mut diff = self
40            .inner
41            .diff_tree_to_workdir_with_index(Some(&head_tree), Some(&mut diff_opts))?;
42
43        let mut find_opts = git2::DiffFindOptions::new();
44        find_opts.renames(true);
45        find_opts.copies(true);
46        find_opts.copies_from_unmodified(true);
47        diff.find_similar(Some(&mut find_opts))?;
48
49        collect_changes(&diff)
50    }
51
52    /// # Errors
53    ///
54    /// Returns [`GitError::RefNotFound`] if the base reference cannot be resolved.
55    pub fn changed_files_from_head(&self, base: &str) -> Result<Vec<FileChange>> {
56        self.changed_files(Some(base), "HEAD")
57    }
58
59    fn resolve_tree(&self, refspec: &str) -> Result<git2::Tree<'_>> {
60        let obj = self
61            .inner
62            .revparse_single(refspec)
63            .or_else(|original_err| self.try_remote_tracking_ref(refspec).ok_or(original_err))
64            .map_err(|source| GitError::RefNotFound {
65                refspec: refspec.to_string(),
66                source,
67            })?;
68
69        obj.peel_to_tree().map_err(|source| GitError::NotATree {
70            refspec: refspec.to_string(),
71            source,
72        })
73    }
74
75    fn try_remote_tracking_ref(&self, refspec: &str) -> Option<git2::Object<'_>> {
76        if !refspec.starts_with("refs/") && refspec.contains('/') {
77            self.inner
78                .revparse_single(&format!("refs/remotes/{refspec}"))
79                .ok()
80        } else {
81            None
82        }
83    }
84}
85
86fn collect_changes(diff: &git2::Diff<'_>) -> Result<Vec<FileChange>> {
87    let mut changes = Vec::new();
88
89    for delta in diff.deltas() {
90        let status = match delta.status() {
91            git2::Delta::Added | git2::Delta::Untracked => FileStatus::Added,
92            git2::Delta::Deleted => FileStatus::Deleted,
93            git2::Delta::Modified => FileStatus::Modified,
94            git2::Delta::Renamed => FileStatus::Renamed,
95            git2::Delta::Copied => FileStatus::Copied,
96            git2::Delta::Typechange => FileStatus::Typechange,
97            git2::Delta::Unmodified
98            | git2::Delta::Ignored
99            | git2::Delta::Unreadable
100            | git2::Delta::Conflicted => continue,
101        };
102
103        let path = delta
104            .new_file()
105            .path()
106            .or_else(|| delta.old_file().path())
107            .map(PathBuf::from)
108            .ok_or(GitError::MissingDeltaPath)?;
109
110        let mut change = FileChange::new(path, status);
111
112        if status == FileStatus::Renamed || status == FileStatus::Copied {
113            let old_path = delta.old_file().path().ok_or(GitError::MissingDeltaPath)?;
114            change = change.with_old_path(old_path.to_path_buf());
115        }
116
117        changes.push(change);
118    }
119
120    Ok(changes)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::super::tests::setup_test_repo;
126    use crate::{FileChange, FileStatus, GitError};
127    use std::fs;
128    use std::path::PathBuf;
129
130    #[test]
131    fn detect_added_file() -> anyhow::Result<()> {
132        let (dir, repo) = setup_test_repo()?;
133
134        fs::write(dir.path().join("new_file.txt"), "content")?;
135
136        let mut index = repo.inner.index()?;
137        index.add_path(std::path::Path::new("new_file.txt"))?;
138        index.write()?;
139
140        let sig = git2::Signature::now("Test", "test@example.com")?;
141        let tree_id = index.write_tree()?;
142        let tree = repo.inner.find_tree(tree_id)?;
143        let parent = repo.inner.head()?.peel_to_commit()?;
144        repo.inner
145            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
146
147        let changes = repo.changed_files_from_head("HEAD~1")?;
148        assert_eq!(changes.len(), 1);
149        assert_eq!(changes[0].status(), FileStatus::Added);
150        assert_eq!(changes[0].path().to_string_lossy(), "new_file.txt");
151
152        Ok(())
153    }
154
155    #[test]
156    fn detect_modified_file() -> anyhow::Result<()> {
157        let (dir, repo) = setup_test_repo()?;
158
159        fs::write(dir.path().join("file.txt"), "initial")?;
160        let mut index = repo.inner.index()?;
161        index.add_path(std::path::Path::new("file.txt"))?;
162        index.write()?;
163
164        let sig = git2::Signature::now("Test", "test@example.com")?;
165        let tree_id = index.write_tree()?;
166        let tree = repo.inner.find_tree(tree_id)?;
167        let parent = repo.inner.head()?.peel_to_commit()?;
168        repo.inner
169            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
170
171        fs::write(dir.path().join("file.txt"), "modified")?;
172        let mut index = repo.inner.index()?;
173        index.add_path(std::path::Path::new("file.txt"))?;
174        index.write()?;
175
176        let tree_id = index.write_tree()?;
177        let tree = repo.inner.find_tree(tree_id)?;
178        let parent = repo.inner.head()?.peel_to_commit()?;
179        repo.inner
180            .commit(Some("HEAD"), &sig, &sig, "Modify file", &tree, &[&parent])?;
181
182        let changes = repo.changed_files_from_head("HEAD~1")?;
183        assert_eq!(changes.len(), 1);
184        assert_eq!(changes[0].status(), FileStatus::Modified);
185        assert_eq!(changes[0].path(), &PathBuf::from("file.txt"));
186
187        Ok(())
188    }
189
190    #[test]
191    fn detect_deleted_file() -> anyhow::Result<()> {
192        let (dir, repo) = setup_test_repo()?;
193
194        fs::write(dir.path().join("file.txt"), "content")?;
195        let mut index = repo.inner.index()?;
196        index.add_path(std::path::Path::new("file.txt"))?;
197        index.write()?;
198
199        let sig = git2::Signature::now("Test", "test@example.com")?;
200        let tree_id = index.write_tree()?;
201        let tree = repo.inner.find_tree(tree_id)?;
202        let parent = repo.inner.head()?.peel_to_commit()?;
203        repo.inner
204            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
205
206        fs::remove_file(dir.path().join("file.txt"))?;
207        let mut index = repo.inner.index()?;
208        index.remove_path(std::path::Path::new("file.txt"))?;
209        index.write()?;
210
211        let tree_id = index.write_tree()?;
212        let tree = repo.inner.find_tree(tree_id)?;
213        let parent = repo.inner.head()?.peel_to_commit()?;
214        repo.inner
215            .commit(Some("HEAD"), &sig, &sig, "Delete file", &tree, &[&parent])?;
216
217        let changes = repo.changed_files_from_head("HEAD~1")?;
218        assert_eq!(changes.len(), 1);
219        assert_eq!(changes[0].status(), FileStatus::Deleted);
220        assert_eq!(changes[0].path(), &PathBuf::from("file.txt"));
221
222        Ok(())
223    }
224
225    #[test]
226    fn ref_not_found_error() -> anyhow::Result<()> {
227        let (_dir, repo) = setup_test_repo()?;
228
229        let result = repo.changed_files_from_head("nonexistent-ref");
230        assert!(matches!(
231            result,
232            Err(GitError::RefNotFound { ref refspec, .. }) if refspec == "nonexistent-ref"
233        ));
234
235        Ok(())
236    }
237
238    #[test]
239    fn resolve_remote_tracking_ref_shorthand() -> anyhow::Result<()> {
240        let (dir, repo) = setup_test_repo()?;
241
242        let base_commit_oid = repo.inner.head()?.peel_to_commit()?.id();
243
244        fs::write(dir.path().join("feature.txt"), "content")?;
245        let mut index = repo.inner.index()?;
246        index.add_path(std::path::Path::new("feature.txt"))?;
247        index.write()?;
248
249        let sig = git2::Signature::now("Test", "test@example.com")?;
250        let tree_id = index.write_tree()?;
251        let tree = repo.inner.find_tree(tree_id)?;
252        let parent = repo.inner.head()?.peel_to_commit()?;
253        repo.inner
254            .commit(Some("HEAD"), &sig, &sig, "Add feature", &tree, &[&parent])?;
255
256        repo.inner
257            .reference("refs/remotes/origin/main", base_commit_oid, false, "")?;
258
259        let changes = repo.changed_files_from_head("origin/main")?;
260
261        assert_eq!(changes.len(), 1);
262        assert_eq!(changes[0].status(), FileStatus::Added);
263        assert_eq!(changes[0].path().to_string_lossy(), "feature.txt");
264
265        Ok(())
266    }
267
268    #[test]
269    fn remote_tracking_ref_not_found_returns_error() -> anyhow::Result<()> {
270        let (_dir, repo) = setup_test_repo()?;
271
272        let result = repo.changed_files_from_head("origin/nonexistent");
273        assert!(matches!(
274            result,
275            Err(GitError::RefNotFound { ref refspec, .. }) if refspec == "origin/nonexistent"
276        ));
277
278        Ok(())
279    }
280
281    #[test]
282    fn detect_renamed_file() -> anyhow::Result<()> {
283        let (dir, repo) = setup_test_repo()?;
284        let path = std::path::Path::new;
285
286        fs::write(dir.path().join("original.txt"), "content")?;
287        let mut index = repo.inner.index()?;
288        index.add_path(path("original.txt"))?;
289        index.write()?;
290
291        let sig = git2::Signature::now("Test", "test@example.com")?;
292        let tree_id = index.write_tree()?;
293        let tree = repo.inner.find_tree(tree_id)?;
294        let parent = repo.inner.head()?.peel_to_commit()?;
295        repo.inner
296            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
297
298        fs::rename(
299            dir.path().join("original.txt"),
300            dir.path().join("renamed.txt"),
301        )?;
302        let mut index = repo.inner.index()?;
303        index.remove_path(path("original.txt"))?;
304        index.add_path(path("renamed.txt"))?;
305        index.write()?;
306
307        let tree_id = index.write_tree()?;
308        let tree = repo.inner.find_tree(tree_id)?;
309        let parent = repo.inner.head()?.peel_to_commit()?;
310        repo.inner
311            .commit(Some("HEAD"), &sig, &sig, "Rename file", &tree, &[&parent])?;
312
313        let changes = repo.changed_files_from_head("HEAD~1")?;
314        assert_eq!(changes.len(), 1);
315
316        let rename = &changes[0];
317        assert_eq!(rename.status(), FileStatus::Renamed);
318        assert_eq!(rename.path(), &PathBuf::from("renamed.txt"));
319        assert_eq!(rename.old_path(), Some(&PathBuf::from("original.txt")));
320
321        Ok(())
322    }
323
324    #[test]
325    fn new_file_alongside_existing_is_detected_as_added_or_copied() -> anyhow::Result<()> {
326        let (dir, repo) = setup_test_repo()?;
327        let path = std::path::Path::new;
328
329        let content = "This is a longer piece of content.";
330        fs::write(dir.path().join("original.txt"), content)?;
331        let mut index = repo.inner.index()?;
332        index.add_path(path("original.txt"))?;
333        index.write()?;
334
335        let sig = git2::Signature::now("Test", "test@example.com")?;
336        let tree_id = index.write_tree()?;
337        let tree = repo.inner.find_tree(tree_id)?;
338        let parent = repo.inner.head()?.peel_to_commit()?;
339        repo.inner
340            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
341
342        fs::copy(dir.path().join("original.txt"), dir.path().join("copy.txt"))?;
343        let mut index = repo.inner.index()?;
344        index.add_path(path("copy.txt"))?;
345        index.write()?;
346
347        let tree_id = index.write_tree()?;
348        let tree = repo.inner.find_tree(tree_id)?;
349        let parent = repo.inner.head()?.peel_to_commit()?;
350        repo.inner
351            .commit(Some("HEAD"), &sig, &sig, "Copy file", &tree, &[&parent])?;
352
353        let changes = repo.changed_files_from_head("HEAD~1")?;
354        assert_eq!(changes.len(), 1);
355
356        let change = &changes[0];
357        assert!(
358            change.status() == FileStatus::Added || change.status() == FileStatus::Copied,
359            "new file should be detected as Added or Copied, got {:?}",
360            change.status()
361        );
362        assert_eq!(change.path(), &PathBuf::from("copy.txt"));
363
364        Ok(())
365    }
366
367    #[test]
368    fn none_base_shows_all_files_as_added() -> anyhow::Result<()> {
369        let (dir, repo) = setup_test_repo()?;
370
371        fs::write(dir.path().join("a.txt"), "alpha")?;
372        fs::write(dir.path().join("b.txt"), "beta")?;
373
374        let mut index = repo.inner.index()?;
375        index.add_path(std::path::Path::new("a.txt"))?;
376        index.add_path(std::path::Path::new("b.txt"))?;
377        index.write()?;
378
379        let sig = git2::Signature::now("Test", "test@example.com")?;
380        let tree_id = index.write_tree()?;
381        let tree = repo.inner.find_tree(tree_id)?;
382        let parent = repo.inner.head()?.peel_to_commit()?;
383        repo.inner
384            .commit(Some("HEAD"), &sig, &sig, "Add files", &tree, &[&parent])?;
385
386        let changes = repo.changed_files(None, "HEAD")?;
387
388        assert_eq!(changes.len(), 2);
389        assert!(
390            changes.iter().all(|c| c.status() == FileStatus::Added),
391            "all files should be Added when diffing against empty tree"
392        );
393
394        let paths: Vec<_> = changes.iter().map(|c| c.path().clone()).collect();
395        assert!(paths.contains(&PathBuf::from("a.txt")));
396        assert!(paths.contains(&PathBuf::from("b.txt")));
397
398        Ok(())
399    }
400
401    #[test]
402    fn blob_ref_returns_not_a_tree_error() -> anyhow::Result<()> {
403        let (_dir, repo) = setup_test_repo()?;
404
405        let blob_oid = repo.inner.blob(b"not a tree")?;
406        repo.inner
407            .reference("refs/heads/blob-ref", blob_oid, false, "")?;
408
409        let result = repo.changed_files_from_head("refs/heads/blob-ref");
410        assert!(matches!(
411            result,
412            Err(GitError::NotATree { ref refspec, .. }) if refspec == "refs/heads/blob-ref"
413        ));
414
415        Ok(())
416    }
417
418    #[test]
419    fn heterogeneous_diff_detects_added_modified_and_deleted() -> anyhow::Result<()> {
420        let (dir, repo) = setup_test_repo()?;
421        let sig = git2::Signature::now("Test", "test@example.com")?;
422
423        fs::write(dir.path().join("to_modify.txt"), "original")?;
424        fs::write(dir.path().join("to_delete.txt"), "doomed")?;
425
426        let mut index = repo.inner.index()?;
427        index.add_path(std::path::Path::new("to_modify.txt"))?;
428        index.add_path(std::path::Path::new("to_delete.txt"))?;
429        index.write()?;
430
431        let tree_id = index.write_tree()?;
432        let tree = repo.inner.find_tree(tree_id)?;
433        let parent = repo.inner.head()?.peel_to_commit()?;
434        repo.inner
435            .commit(Some("HEAD"), &sig, &sig, "Setup files", &tree, &[&parent])?;
436
437        fs::write(dir.path().join("to_modify.txt"), "changed")?;
438        fs::remove_file(dir.path().join("to_delete.txt"))?;
439        fs::write(dir.path().join("brand_new.txt"), "hello")?;
440
441        let mut index = repo.inner.index()?;
442        index.add_path(std::path::Path::new("to_modify.txt"))?;
443        index.remove_path(std::path::Path::new("to_delete.txt"))?;
444        index.add_path(std::path::Path::new("brand_new.txt"))?;
445        index.write()?;
446
447        let tree_id = index.write_tree()?;
448        let tree = repo.inner.find_tree(tree_id)?;
449        let parent = repo.inner.head()?.peel_to_commit()?;
450        repo.inner.commit(
451            Some("HEAD"),
452            &sig,
453            &sig,
454            "Add, modify, delete",
455            &tree,
456            &[&parent],
457        )?;
458
459        let changes = repo.changed_files_from_head("HEAD~1")?;
460
461        let statuses: std::collections::HashMap<_, _> = changes
462            .iter()
463            .map(|c| (c.path().to_string_lossy().into_owned(), c.status()))
464            .collect();
465
466        assert_eq!(statuses.len(), 3);
467        assert_eq!(statuses["brand_new.txt"], FileStatus::Added);
468        assert_eq!(statuses["to_modify.txt"], FileStatus::Modified);
469        assert_eq!(statuses["to_delete.txt"], FileStatus::Deleted);
470
471        Ok(())
472    }
473
474    #[test]
475    fn diff_between_two_explicit_non_head_refs() -> anyhow::Result<()> {
476        let (dir, repo) = setup_test_repo()?;
477        let sig = git2::Signature::now("Test", "test@example.com")?;
478
479        let base_oid = repo.inner.head()?.peel_to_commit()?.id();
480
481        fs::write(dir.path().join("first.txt"), "one")?;
482        let mut index = repo.inner.index()?;
483        index.add_path(std::path::Path::new("first.txt"))?;
484        index.write()?;
485        let tree_id = index.write_tree()?;
486        let tree = repo.inner.find_tree(tree_id)?;
487        let parent = repo.inner.find_commit(base_oid)?;
488        let mid_oid =
489            repo.inner
490                .commit(Some("HEAD"), &sig, &sig, "First commit", &tree, &[&parent])?;
491
492        fs::write(dir.path().join("second.txt"), "two")?;
493        let mut index = repo.inner.index()?;
494        index.add_path(std::path::Path::new("second.txt"))?;
495        index.write()?;
496        let tree_id = index.write_tree()?;
497        let tree = repo.inner.find_tree(tree_id)?;
498        let parent = repo.inner.find_commit(mid_oid)?;
499        let tip_oid =
500            repo.inner
501                .commit(Some("HEAD"), &sig, &sig, "Second commit", &tree, &[&parent])?;
502
503        let base_hex = mid_oid.to_string();
504        let tip_hex = tip_oid.to_string();
505
506        let changes = repo.changed_files(Some(&base_hex), &tip_hex)?;
507
508        assert_eq!(changes.len(), 1);
509        assert_eq!(changes[0].status(), FileStatus::Added);
510        assert_eq!(changes[0].path(), &PathBuf::from("second.txt"));
511
512        Ok(())
513    }
514
515    #[test]
516    fn file_change_with_old_path() {
517        let change = FileChange::new(PathBuf::from("new.txt"), FileStatus::Renamed)
518            .with_old_path(PathBuf::from("old.txt"));
519
520        assert_eq!(change.path(), &PathBuf::from("new.txt"));
521        assert_eq!(change.status(), FileStatus::Renamed);
522        assert_eq!(change.old_path(), Some(&PathBuf::from("old.txt")));
523    }
524
525    #[test]
526    fn uncommitted_changes_detects_unstaged_modification() -> anyhow::Result<()> {
527        let (dir, repo) = setup_test_repo()?;
528
529        fs::write(dir.path().join("file.txt"), "initial")?;
530        let mut index = repo.inner.index()?;
531        index.add_path(std::path::Path::new("file.txt"))?;
532        index.write()?;
533
534        let sig = git2::Signature::now("Test", "test@example.com")?;
535        let tree_id = index.write_tree()?;
536        let tree = repo.inner.find_tree(tree_id)?;
537        let parent = repo.inner.head()?.peel_to_commit()?;
538        repo.inner
539            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
540
541        fs::write(dir.path().join("file.txt"), "modified")?;
542
543        let changes = repo.uncommitted_changes()?;
544        assert_eq!(changes.len(), 1);
545        assert_eq!(changes[0].status(), FileStatus::Modified);
546        assert_eq!(changes[0].path(), &PathBuf::from("file.txt"));
547
548        Ok(())
549    }
550
551    #[test]
552    fn uncommitted_changes_detects_staged_addition() -> anyhow::Result<()> {
553        let (dir, repo) = setup_test_repo()?;
554
555        fs::write(dir.path().join("new_file.txt"), "content")?;
556        let mut index = repo.inner.index()?;
557        index.add_path(std::path::Path::new("new_file.txt"))?;
558        index.write()?;
559
560        let changes = repo.uncommitted_changes()?;
561        assert_eq!(changes.len(), 1);
562        assert_eq!(changes[0].status(), FileStatus::Added);
563        assert_eq!(changes[0].path(), &PathBuf::from("new_file.txt"));
564
565        Ok(())
566    }
567
568    #[test]
569    fn uncommitted_changes_detects_untracked_file() -> anyhow::Result<()> {
570        let (dir, repo) = setup_test_repo()?;
571
572        fs::write(dir.path().join("untracked.txt"), "content")?;
573
574        let changes = repo.uncommitted_changes()?;
575        assert_eq!(changes.len(), 1);
576        assert_eq!(changes[0].status(), FileStatus::Added);
577        assert_eq!(changes[0].path(), &PathBuf::from("untracked.txt"));
578
579        Ok(())
580    }
581
582    #[test]
583    fn uncommitted_changes_on_clean_tree_returns_empty() -> anyhow::Result<()> {
584        let (_dir, repo) = setup_test_repo()?;
585
586        let changes = repo.uncommitted_changes()?;
587        assert!(changes.is_empty());
588
589        Ok(())
590    }
591
592    #[test]
593    fn uncommitted_changes_combines_staged_and_unstaged() -> anyhow::Result<()> {
594        let (dir, repo) = setup_test_repo()?;
595
596        fs::write(dir.path().join("committed.txt"), "initial")?;
597        let mut index = repo.inner.index()?;
598        index.add_path(std::path::Path::new("committed.txt"))?;
599        index.write()?;
600
601        let sig = git2::Signature::now("Test", "test@example.com")?;
602        let tree_id = index.write_tree()?;
603        let tree = repo.inner.find_tree(tree_id)?;
604        let parent = repo.inner.head()?.peel_to_commit()?;
605        repo.inner
606            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
607
608        fs::write(dir.path().join("staged.txt"), "staged content")?;
609        let mut index = repo.inner.index()?;
610        index.add_path(std::path::Path::new("staged.txt"))?;
611        index.write()?;
612
613        fs::write(dir.path().join("committed.txt"), "modified")?;
614
615        let changes = repo.uncommitted_changes()?;
616        assert_eq!(changes.len(), 2);
617
618        let statuses: std::collections::HashMap<_, _> = changes
619            .iter()
620            .map(|c| (c.path().to_string_lossy().into_owned(), c.status()))
621            .collect();
622
623        assert_eq!(statuses["staged.txt"], FileStatus::Added);
624        assert_eq!(statuses["committed.txt"], FileStatus::Modified);
625
626        Ok(())
627    }
628}