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 = match base {
15            Some(refspec) => Some(self.resolve_tree(refspec)?),
16            None => None,
17        };
18
19        let mut diff = self
20            .inner
21            .diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
22
23        let mut find_opts = git2::DiffFindOptions::new();
24        find_opts.renames(true);
25        find_opts.copies(true);
26        find_opts.copies_from_unmodified(true);
27        diff.find_similar(Some(&mut find_opts))?;
28
29        let mut changes = Vec::new();
30
31        for delta in diff.deltas() {
32            let status = match delta.status() {
33                git2::Delta::Added => FileStatus::Added,
34                git2::Delta::Deleted => FileStatus::Deleted,
35                git2::Delta::Modified => FileStatus::Modified,
36                git2::Delta::Renamed => FileStatus::Renamed,
37                git2::Delta::Copied => FileStatus::Copied,
38                _ => continue,
39            };
40
41            let path = delta
42                .new_file()
43                .path()
44                .or_else(|| delta.old_file().path())
45                .map(PathBuf::from)
46                .ok_or(GitError::MissingDeltaPath)?;
47
48            let mut change = FileChange::new(path, status);
49
50            if status == FileStatus::Renamed || status == FileStatus::Copied {
51                if let Some(old_path) = delta.old_file().path() {
52                    change = change.with_old_path(old_path.to_path_buf());
53                }
54            }
55
56            changes.push(change);
57        }
58
59        Ok(changes)
60    }
61
62    /// # Errors
63    ///
64    /// Returns [`GitError::RefNotFound`] if the base reference cannot be resolved.
65    pub fn changed_files_from_head(&self, base: &str) -> Result<Vec<FileChange>> {
66        self.changed_files(Some(base), "HEAD")
67    }
68
69    fn resolve_tree(&self, refspec: &str) -> Result<git2::Tree<'_>> {
70        let obj = self
71            .inner
72            .revparse_single(refspec)
73            .map_err(|_| GitError::RefNotFound {
74                refspec: refspec.to_string(),
75            })?;
76
77        obj.peel_to_tree().map_err(|_| GitError::RefNotFound {
78            refspec: refspec.to_string(),
79        })
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::super::tests::setup_test_repo;
86    use crate::{FileChange, FileStatus};
87    use std::fs;
88    use std::path::PathBuf;
89
90    #[test]
91    fn detect_added_file() -> anyhow::Result<()> {
92        let (dir, repo) = setup_test_repo()?;
93
94        fs::write(dir.path().join("new_file.txt"), "content")?;
95
96        let mut index = repo.inner.index()?;
97        index.add_path(std::path::Path::new("new_file.txt"))?;
98        index.write()?;
99
100        let sig = git2::Signature::now("Test", "test@example.com")?;
101        let tree_id = index.write_tree()?;
102        let tree = repo.inner.find_tree(tree_id)?;
103        let parent = repo.inner.head()?.peel_to_commit()?;
104        repo.inner
105            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
106
107        let changes = repo.changed_files_from_head("HEAD~1")?;
108        assert_eq!(changes.len(), 1);
109        assert_eq!(changes[0].status, FileStatus::Added);
110        assert_eq!(changes[0].path.to_string_lossy(), "new_file.txt");
111
112        Ok(())
113    }
114
115    #[test]
116    fn detect_modified_file() -> anyhow::Result<()> {
117        let (dir, repo) = setup_test_repo()?;
118
119        fs::write(dir.path().join("file.txt"), "initial")?;
120        let mut index = repo.inner.index()?;
121        index.add_path(std::path::Path::new("file.txt"))?;
122        index.write()?;
123
124        let sig = git2::Signature::now("Test", "test@example.com")?;
125        let tree_id = index.write_tree()?;
126        let tree = repo.inner.find_tree(tree_id)?;
127        let parent = repo.inner.head()?.peel_to_commit()?;
128        repo.inner
129            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
130
131        fs::write(dir.path().join("file.txt"), "modified")?;
132        let mut index = repo.inner.index()?;
133        index.add_path(std::path::Path::new("file.txt"))?;
134        index.write()?;
135
136        let tree_id = index.write_tree()?;
137        let tree = repo.inner.find_tree(tree_id)?;
138        let parent = repo.inner.head()?.peel_to_commit()?;
139        repo.inner
140            .commit(Some("HEAD"), &sig, &sig, "Modify file", &tree, &[&parent])?;
141
142        let changes = repo.changed_files_from_head("HEAD~1")?;
143        assert_eq!(changes.len(), 1);
144        assert_eq!(changes[0].status, FileStatus::Modified);
145
146        Ok(())
147    }
148
149    #[test]
150    fn detect_deleted_file() -> anyhow::Result<()> {
151        let (dir, repo) = setup_test_repo()?;
152
153        fs::write(dir.path().join("file.txt"), "content")?;
154        let mut index = repo.inner.index()?;
155        index.add_path(std::path::Path::new("file.txt"))?;
156        index.write()?;
157
158        let sig = git2::Signature::now("Test", "test@example.com")?;
159        let tree_id = index.write_tree()?;
160        let tree = repo.inner.find_tree(tree_id)?;
161        let parent = repo.inner.head()?.peel_to_commit()?;
162        repo.inner
163            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
164
165        fs::remove_file(dir.path().join("file.txt"))?;
166        let mut index = repo.inner.index()?;
167        index.remove_path(std::path::Path::new("file.txt"))?;
168        index.write()?;
169
170        let tree_id = index.write_tree()?;
171        let tree = repo.inner.find_tree(tree_id)?;
172        let parent = repo.inner.head()?.peel_to_commit()?;
173        repo.inner
174            .commit(Some("HEAD"), &sig, &sig, "Delete file", &tree, &[&parent])?;
175
176        let changes = repo.changed_files_from_head("HEAD~1")?;
177        assert_eq!(changes.len(), 1);
178        assert_eq!(changes[0].status, FileStatus::Deleted);
179
180        Ok(())
181    }
182
183    #[test]
184    fn ref_not_found_error() -> anyhow::Result<()> {
185        let (_dir, repo) = setup_test_repo()?;
186
187        let result = repo.changed_files_from_head("nonexistent-ref");
188        assert!(result.is_err());
189
190        Ok(())
191    }
192
193    #[test]
194    fn detect_renamed_file() -> anyhow::Result<()> {
195        let (dir, repo) = setup_test_repo()?;
196        let path = std::path::Path::new;
197
198        fs::write(dir.path().join("original.txt"), "content")?;
199        let mut index = repo.inner.index()?;
200        index.add_path(path("original.txt"))?;
201        index.write()?;
202
203        let sig = git2::Signature::now("Test", "test@example.com")?;
204        let tree_id = index.write_tree()?;
205        let tree = repo.inner.find_tree(tree_id)?;
206        let parent = repo.inner.head()?.peel_to_commit()?;
207        repo.inner
208            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
209
210        fs::rename(
211            dir.path().join("original.txt"),
212            dir.path().join("renamed.txt"),
213        )?;
214        let mut index = repo.inner.index()?;
215        index.remove_path(path("original.txt"))?;
216        index.add_path(path("renamed.txt"))?;
217        index.write()?;
218
219        let tree_id = index.write_tree()?;
220        let tree = repo.inner.find_tree(tree_id)?;
221        let parent = repo.inner.head()?.peel_to_commit()?;
222        repo.inner
223            .commit(Some("HEAD"), &sig, &sig, "Rename file", &tree, &[&parent])?;
224
225        let changes = repo.changed_files_from_head("HEAD~1")?;
226        assert_eq!(changes.len(), 1);
227
228        let rename = &changes[0];
229        assert_eq!(rename.status, FileStatus::Renamed);
230        assert_eq!(rename.path, PathBuf::from("renamed.txt"));
231        assert_eq!(rename.old_path, Some(PathBuf::from("original.txt")));
232
233        Ok(())
234    }
235
236    #[test]
237    fn new_file_alongside_existing_is_detected_as_added() -> anyhow::Result<()> {
238        let (dir, repo) = setup_test_repo()?;
239        let path = std::path::Path::new;
240
241        let content = "This is a longer piece of content.";
242        fs::write(dir.path().join("original.txt"), content)?;
243        let mut index = repo.inner.index()?;
244        index.add_path(path("original.txt"))?;
245        index.write()?;
246
247        let sig = git2::Signature::now("Test", "test@example.com")?;
248        let tree_id = index.write_tree()?;
249        let tree = repo.inner.find_tree(tree_id)?;
250        let parent = repo.inner.head()?.peel_to_commit()?;
251        repo.inner
252            .commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&parent])?;
253
254        fs::copy(dir.path().join("original.txt"), dir.path().join("copy.txt"))?;
255        let mut index = repo.inner.index()?;
256        index.add_path(path("copy.txt"))?;
257        index.write()?;
258
259        let tree_id = index.write_tree()?;
260        let tree = repo.inner.find_tree(tree_id)?;
261        let parent = repo.inner.head()?.peel_to_commit()?;
262        repo.inner
263            .commit(Some("HEAD"), &sig, &sig, "Copy file", &tree, &[&parent])?;
264
265        let changes = repo.changed_files_from_head("HEAD~1")?;
266        assert_eq!(changes.len(), 1);
267
268        let change = &changes[0];
269        assert!(
270            change.status == FileStatus::Added || change.status == FileStatus::Copied,
271            "new file should be detected as Added or Copied, got {:?}",
272            change.status
273        );
274        assert_eq!(change.path, PathBuf::from("copy.txt"));
275
276        Ok(())
277    }
278
279    #[test]
280    fn file_change_with_old_path() {
281        let change = FileChange::new(PathBuf::from("new.txt"), FileStatus::Renamed)
282            .with_old_path(PathBuf::from("old.txt"));
283
284        assert_eq!(change.path, PathBuf::from("new.txt"));
285        assert_eq!(change.status, FileStatus::Renamed);
286        assert_eq!(change.old_path, Some(PathBuf::from("old.txt")));
287    }
288}