changeset_git/repository/
diff.rs1use std::path::PathBuf;
2
3use crate::{FileChange, FileStatus, GitError, Result};
4
5use super::Repository;
6
7impl Repository {
8 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 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}