1use 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 = 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 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}