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 collect_changes(&diff)
27 }
28
29 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 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}