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        let mut changes = Vec::new();
27
28        for delta in diff.deltas() {
29            let status = match delta.status() {
30                git2::Delta::Added => FileStatus::Added,
31                git2::Delta::Deleted => FileStatus::Deleted,
32                git2::Delta::Modified => FileStatus::Modified,
33                git2::Delta::Renamed => FileStatus::Renamed,
34                git2::Delta::Copied => FileStatus::Copied,
35                git2::Delta::Typechange => FileStatus::Typechange,
36                git2::Delta::Unmodified
37                | git2::Delta::Ignored
38                | git2::Delta::Untracked
39                | git2::Delta::Unreadable
40                | git2::Delta::Conflicted => continue,
41            };
42
43            let path = delta
44                .new_file()
45                .path()
46                .or_else(|| delta.old_file().path())
47                .map(PathBuf::from)
48                .ok_or(GitError::MissingDeltaPath)?;
49
50            let mut change = FileChange::new(path, status);
51
52            if status == FileStatus::Renamed || status == FileStatus::Copied {
53                let old_path = delta.old_file().path().ok_or(GitError::MissingDeltaPath)?;
54                change = change.with_old_path(old_path.to_path_buf());
55            }
56
57            changes.push(change);
58        }
59
60        Ok(changes)
61    }
62
63    /// # Errors
64    ///
65    /// Returns [`GitError::RefNotFound`] if the base reference cannot be resolved.
66    pub fn changed_files_from_head(&self, base: &str) -> Result<Vec<FileChange>> {
67        self.changed_files(Some(base), "HEAD")
68    }
69
70    fn resolve_tree(&self, refspec: &str) -> Result<git2::Tree<'_>> {
71        let obj = self
72            .inner
73            .revparse_single(refspec)
74            .or_else(|original_err| self.try_remote_tracking_ref(refspec).ok_or(original_err))
75            .map_err(|source| GitError::RefNotFound {
76                refspec: refspec.to_string(),
77                source,
78            })?;
79
80        obj.peel_to_tree().map_err(|source| GitError::NotATree {
81            refspec: refspec.to_string(),
82            source,
83        })
84    }
85
86    fn try_remote_tracking_ref(&self, refspec: &str) -> Option<git2::Object<'_>> {
87        if !refspec.starts_with("refs/") && refspec.contains('/') {
88            self.inner
89                .revparse_single(&format!("refs/remotes/{refspec}"))
90                .ok()
91        } else {
92            None
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::super::tests::setup_test_repo;
100    use crate::{FileChange, FileStatus, GitError};
101    use std::fs;
102    use std::path::PathBuf;
103
104    #[test]
105    fn detect_added_file() -> anyhow::Result<()> {
106        let (dir, repo) = setup_test_repo()?;
107
108        fs::write(dir.path().join("new_file.txt"), "content")?;
109
110        let mut index = repo.inner.index()?;
111        index.add_path(std::path::Path::new("new_file.txt"))?;
112        index.write()?;
113
114        let sig = git2::Signature::now("Test", "test@example.com")?;
115        let tree_id = index.write_tree()?;
116        let tree = repo.inner.find_tree(tree_id)?;
117        let parent = repo.inner.head()?.peel_to_commit()?;
118        repo.inner
119            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
120
121        let changes = repo.changed_files_from_head("HEAD~1")?;
122        assert_eq!(changes.len(), 1);
123        assert_eq!(changes[0].status, FileStatus::Added);
124        assert_eq!(changes[0].path.to_string_lossy(), "new_file.txt");
125
126        Ok(())
127    }
128
129    #[test]
130    fn detect_modified_file() -> anyhow::Result<()> {
131        let (dir, repo) = setup_test_repo()?;
132
133        fs::write(dir.path().join("file.txt"), "initial")?;
134        let mut index = repo.inner.index()?;
135        index.add_path(std::path::Path::new("file.txt"))?;
136        index.write()?;
137
138        let sig = git2::Signature::now("Test", "test@example.com")?;
139        let tree_id = index.write_tree()?;
140        let tree = repo.inner.find_tree(tree_id)?;
141        let parent = repo.inner.head()?.peel_to_commit()?;
142        repo.inner
143            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
144
145        fs::write(dir.path().join("file.txt"), "modified")?;
146        let mut index = repo.inner.index()?;
147        index.add_path(std::path::Path::new("file.txt"))?;
148        index.write()?;
149
150        let tree_id = index.write_tree()?;
151        let tree = repo.inner.find_tree(tree_id)?;
152        let parent = repo.inner.head()?.peel_to_commit()?;
153        repo.inner
154            .commit(Some("HEAD"), &sig, &sig, "Modify file", &tree, &[&parent])?;
155
156        let changes = repo.changed_files_from_head("HEAD~1")?;
157        assert_eq!(changes.len(), 1);
158        assert_eq!(changes[0].status, FileStatus::Modified);
159        assert_eq!(changes[0].path, PathBuf::from("file.txt"));
160
161        Ok(())
162    }
163
164    #[test]
165    fn detect_deleted_file() -> anyhow::Result<()> {
166        let (dir, repo) = setup_test_repo()?;
167
168        fs::write(dir.path().join("file.txt"), "content")?;
169        let mut index = repo.inner.index()?;
170        index.add_path(std::path::Path::new("file.txt"))?;
171        index.write()?;
172
173        let sig = git2::Signature::now("Test", "test@example.com")?;
174        let tree_id = index.write_tree()?;
175        let tree = repo.inner.find_tree(tree_id)?;
176        let parent = repo.inner.head()?.peel_to_commit()?;
177        repo.inner
178            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
179
180        fs::remove_file(dir.path().join("file.txt"))?;
181        let mut index = repo.inner.index()?;
182        index.remove_path(std::path::Path::new("file.txt"))?;
183        index.write()?;
184
185        let tree_id = index.write_tree()?;
186        let tree = repo.inner.find_tree(tree_id)?;
187        let parent = repo.inner.head()?.peel_to_commit()?;
188        repo.inner
189            .commit(Some("HEAD"), &sig, &sig, "Delete file", &tree, &[&parent])?;
190
191        let changes = repo.changed_files_from_head("HEAD~1")?;
192        assert_eq!(changes.len(), 1);
193        assert_eq!(changes[0].status, FileStatus::Deleted);
194        assert_eq!(changes[0].path, PathBuf::from("file.txt"));
195
196        Ok(())
197    }
198
199    #[test]
200    fn ref_not_found_error() -> anyhow::Result<()> {
201        let (_dir, repo) = setup_test_repo()?;
202
203        let result = repo.changed_files_from_head("nonexistent-ref");
204        assert!(matches!(
205            result,
206            Err(GitError::RefNotFound { ref refspec, .. }) if refspec == "nonexistent-ref"
207        ));
208
209        Ok(())
210    }
211
212    #[test]
213    fn resolve_remote_tracking_ref_shorthand() -> anyhow::Result<()> {
214        let (dir, repo) = setup_test_repo()?;
215
216        let base_commit_oid = repo.inner.head()?.peel_to_commit()?.id();
217
218        fs::write(dir.path().join("feature.txt"), "content")?;
219        let mut index = repo.inner.index()?;
220        index.add_path(std::path::Path::new("feature.txt"))?;
221        index.write()?;
222
223        let sig = git2::Signature::now("Test", "test@example.com")?;
224        let tree_id = index.write_tree()?;
225        let tree = repo.inner.find_tree(tree_id)?;
226        let parent = repo.inner.head()?.peel_to_commit()?;
227        repo.inner
228            .commit(Some("HEAD"), &sig, &sig, "Add feature", &tree, &[&parent])?;
229
230        repo.inner
231            .reference("refs/remotes/origin/main", base_commit_oid, false, "")?;
232
233        let changes = repo.changed_files_from_head("origin/main")?;
234
235        assert_eq!(changes.len(), 1);
236        assert_eq!(changes[0].status, FileStatus::Added);
237        assert_eq!(changes[0].path.to_string_lossy(), "feature.txt");
238
239        Ok(())
240    }
241
242    #[test]
243    fn remote_tracking_ref_not_found_returns_error() -> anyhow::Result<()> {
244        let (_dir, repo) = setup_test_repo()?;
245
246        let result = repo.changed_files_from_head("origin/nonexistent");
247        assert!(matches!(
248            result,
249            Err(GitError::RefNotFound { ref refspec, .. }) if refspec == "origin/nonexistent"
250        ));
251
252        Ok(())
253    }
254
255    #[test]
256    fn detect_renamed_file() -> anyhow::Result<()> {
257        let (dir, repo) = setup_test_repo()?;
258        let path = std::path::Path::new;
259
260        fs::write(dir.path().join("original.txt"), "content")?;
261        let mut index = repo.inner.index()?;
262        index.add_path(path("original.txt"))?;
263        index.write()?;
264
265        let sig = git2::Signature::now("Test", "test@example.com")?;
266        let tree_id = index.write_tree()?;
267        let tree = repo.inner.find_tree(tree_id)?;
268        let parent = repo.inner.head()?.peel_to_commit()?;
269        repo.inner
270            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
271
272        fs::rename(
273            dir.path().join("original.txt"),
274            dir.path().join("renamed.txt"),
275        )?;
276        let mut index = repo.inner.index()?;
277        index.remove_path(path("original.txt"))?;
278        index.add_path(path("renamed.txt"))?;
279        index.write()?;
280
281        let tree_id = index.write_tree()?;
282        let tree = repo.inner.find_tree(tree_id)?;
283        let parent = repo.inner.head()?.peel_to_commit()?;
284        repo.inner
285            .commit(Some("HEAD"), &sig, &sig, "Rename file", &tree, &[&parent])?;
286
287        let changes = repo.changed_files_from_head("HEAD~1")?;
288        assert_eq!(changes.len(), 1);
289
290        let rename = &changes[0];
291        assert_eq!(rename.status, FileStatus::Renamed);
292        assert_eq!(rename.path, PathBuf::from("renamed.txt"));
293        assert_eq!(rename.old_path, Some(PathBuf::from("original.txt")));
294
295        Ok(())
296    }
297
298    #[test]
299    fn new_file_alongside_existing_is_detected_as_added_or_copied() -> anyhow::Result<()> {
300        let (dir, repo) = setup_test_repo()?;
301        let path = std::path::Path::new;
302
303        let content = "This is a longer piece of content.";
304        fs::write(dir.path().join("original.txt"), content)?;
305        let mut index = repo.inner.index()?;
306        index.add_path(path("original.txt"))?;
307        index.write()?;
308
309        let sig = git2::Signature::now("Test", "test@example.com")?;
310        let tree_id = index.write_tree()?;
311        let tree = repo.inner.find_tree(tree_id)?;
312        let parent = repo.inner.head()?.peel_to_commit()?;
313        repo.inner
314            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
315
316        fs::copy(dir.path().join("original.txt"), dir.path().join("copy.txt"))?;
317        let mut index = repo.inner.index()?;
318        index.add_path(path("copy.txt"))?;
319        index.write()?;
320
321        let tree_id = index.write_tree()?;
322        let tree = repo.inner.find_tree(tree_id)?;
323        let parent = repo.inner.head()?.peel_to_commit()?;
324        repo.inner
325            .commit(Some("HEAD"), &sig, &sig, "Copy file", &tree, &[&parent])?;
326
327        let changes = repo.changed_files_from_head("HEAD~1")?;
328        assert_eq!(changes.len(), 1);
329
330        let change = &changes[0];
331        assert!(
332            change.status == FileStatus::Added || change.status == FileStatus::Copied,
333            "new file should be detected as Added or Copied, got {:?}",
334            change.status
335        );
336        assert_eq!(change.path, PathBuf::from("copy.txt"));
337
338        Ok(())
339    }
340
341    #[test]
342    fn none_base_shows_all_files_as_added() -> anyhow::Result<()> {
343        let (dir, repo) = setup_test_repo()?;
344
345        fs::write(dir.path().join("a.txt"), "alpha")?;
346        fs::write(dir.path().join("b.txt"), "beta")?;
347
348        let mut index = repo.inner.index()?;
349        index.add_path(std::path::Path::new("a.txt"))?;
350        index.add_path(std::path::Path::new("b.txt"))?;
351        index.write()?;
352
353        let sig = git2::Signature::now("Test", "test@example.com")?;
354        let tree_id = index.write_tree()?;
355        let tree = repo.inner.find_tree(tree_id)?;
356        let parent = repo.inner.head()?.peel_to_commit()?;
357        repo.inner
358            .commit(Some("HEAD"), &sig, &sig, "Add files", &tree, &[&parent])?;
359
360        let changes = repo.changed_files(None, "HEAD")?;
361
362        assert_eq!(changes.len(), 2);
363        assert!(
364            changes.iter().all(|c| c.status == FileStatus::Added),
365            "all files should be Added when diffing against empty tree"
366        );
367
368        let paths: Vec<_> = changes.iter().map(|c| c.path.clone()).collect();
369        assert!(paths.contains(&PathBuf::from("a.txt")));
370        assert!(paths.contains(&PathBuf::from("b.txt")));
371
372        Ok(())
373    }
374
375    #[test]
376    fn blob_ref_returns_not_a_tree_error() -> anyhow::Result<()> {
377        let (_dir, repo) = setup_test_repo()?;
378
379        let blob_oid = repo.inner.blob(b"not a tree")?;
380        repo.inner
381            .reference("refs/heads/blob-ref", blob_oid, false, "")?;
382
383        let result = repo.changed_files_from_head("refs/heads/blob-ref");
384        assert!(matches!(
385            result,
386            Err(GitError::NotATree { ref refspec, .. }) if refspec == "refs/heads/blob-ref"
387        ));
388
389        Ok(())
390    }
391
392    #[test]
393    fn heterogeneous_diff_detects_added_modified_and_deleted() -> anyhow::Result<()> {
394        let (dir, repo) = setup_test_repo()?;
395        let sig = git2::Signature::now("Test", "test@example.com")?;
396
397        fs::write(dir.path().join("to_modify.txt"), "original")?;
398        fs::write(dir.path().join("to_delete.txt"), "doomed")?;
399
400        let mut index = repo.inner.index()?;
401        index.add_path(std::path::Path::new("to_modify.txt"))?;
402        index.add_path(std::path::Path::new("to_delete.txt"))?;
403        index.write()?;
404
405        let tree_id = index.write_tree()?;
406        let tree = repo.inner.find_tree(tree_id)?;
407        let parent = repo.inner.head()?.peel_to_commit()?;
408        repo.inner
409            .commit(Some("HEAD"), &sig, &sig, "Setup files", &tree, &[&parent])?;
410
411        fs::write(dir.path().join("to_modify.txt"), "changed")?;
412        fs::remove_file(dir.path().join("to_delete.txt"))?;
413        fs::write(dir.path().join("brand_new.txt"), "hello")?;
414
415        let mut index = repo.inner.index()?;
416        index.add_path(std::path::Path::new("to_modify.txt"))?;
417        index.remove_path(std::path::Path::new("to_delete.txt"))?;
418        index.add_path(std::path::Path::new("brand_new.txt"))?;
419        index.write()?;
420
421        let tree_id = index.write_tree()?;
422        let tree = repo.inner.find_tree(tree_id)?;
423        let parent = repo.inner.head()?.peel_to_commit()?;
424        repo.inner.commit(
425            Some("HEAD"),
426            &sig,
427            &sig,
428            "Add, modify, delete",
429            &tree,
430            &[&parent],
431        )?;
432
433        let changes = repo.changed_files_from_head("HEAD~1")?;
434
435        let statuses: std::collections::HashMap<_, _> = changes
436            .iter()
437            .map(|c| (c.path.to_string_lossy().into_owned(), c.status))
438            .collect();
439
440        assert_eq!(statuses.len(), 3);
441        assert_eq!(statuses["brand_new.txt"], FileStatus::Added);
442        assert_eq!(statuses["to_modify.txt"], FileStatus::Modified);
443        assert_eq!(statuses["to_delete.txt"], FileStatus::Deleted);
444
445        Ok(())
446    }
447
448    #[test]
449    fn diff_between_two_explicit_non_head_refs() -> anyhow::Result<()> {
450        let (dir, repo) = setup_test_repo()?;
451        let sig = git2::Signature::now("Test", "test@example.com")?;
452
453        let base_oid = repo.inner.head()?.peel_to_commit()?.id();
454
455        fs::write(dir.path().join("first.txt"), "one")?;
456        let mut index = repo.inner.index()?;
457        index.add_path(std::path::Path::new("first.txt"))?;
458        index.write()?;
459        let tree_id = index.write_tree()?;
460        let tree = repo.inner.find_tree(tree_id)?;
461        let parent = repo.inner.find_commit(base_oid)?;
462        let mid_oid =
463            repo.inner
464                .commit(Some("HEAD"), &sig, &sig, "First commit", &tree, &[&parent])?;
465
466        fs::write(dir.path().join("second.txt"), "two")?;
467        let mut index = repo.inner.index()?;
468        index.add_path(std::path::Path::new("second.txt"))?;
469        index.write()?;
470        let tree_id = index.write_tree()?;
471        let tree = repo.inner.find_tree(tree_id)?;
472        let parent = repo.inner.find_commit(mid_oid)?;
473        let tip_oid =
474            repo.inner
475                .commit(Some("HEAD"), &sig, &sig, "Second commit", &tree, &[&parent])?;
476
477        let base_hex = mid_oid.to_string();
478        let tip_hex = tip_oid.to_string();
479
480        let changes = repo.changed_files(Some(&base_hex), &tip_hex)?;
481
482        assert_eq!(changes.len(), 1);
483        assert_eq!(changes[0].status, FileStatus::Added);
484        assert_eq!(changes[0].path, PathBuf::from("second.txt"));
485
486        Ok(())
487    }
488
489    #[test]
490    fn file_change_with_old_path() {
491        let change = FileChange::new(PathBuf::from("new.txt"), FileStatus::Renamed)
492            .with_old_path(PathBuf::from("old.txt"));
493
494        assert_eq!(change.path, PathBuf::from("new.txt"));
495        assert_eq!(change.status, FileStatus::Renamed);
496        assert_eq!(change.old_path, Some(PathBuf::from("old.txt")));
497    }
498}