1use anyhow::Result;
4use std::path::Path;
5
6pub fn open_repo(path: &Path) -> Result<git2::Repository> {
8 git2::Repository::discover(path)
9 .map_err(|e| anyhow::anyhow!("No git repository found at {}: {}", path.display(), e))
10}
11
12#[derive(Debug, Clone)]
13pub enum DiffStatus {
14 Added,
15 Modified,
16 Deleted,
17 Renamed { old_path: String },
18}
19
20#[derive(Debug, Clone)]
21pub struct DiffEntry {
22 pub path: String,
23 pub status: DiffStatus,
24}
25
26pub fn diff_tree_to_tree(
29 repo: &git2::Repository,
30 from_sha: &str,
31 to_sha: &str,
32) -> Result<Vec<DiffEntry>> {
33 let from_obj = repo.revparse_single(from_sha)?;
34 let to_obj = repo.revparse_single(to_sha)?;
35 let from_tree = from_obj.peel_to_commit()?.tree()?;
36 let to_tree = to_obj.peel_to_commit()?.tree()?;
37
38 let mut opts = git2::DiffOptions::new();
39 let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut opts))?;
40
41 let mut find_opts = git2::DiffFindOptions::new();
43 find_opts.renames(true);
44 let mut diff = diff;
45 diff.find_similar(Some(&mut find_opts))?;
46
47 let mut entries = Vec::new();
48 for delta in diff.deltas() {
49 let status = match delta.status() {
50 git2::Delta::Added => DiffStatus::Added,
51 git2::Delta::Modified => DiffStatus::Modified,
52 git2::Delta::Deleted => DiffStatus::Deleted,
53 git2::Delta::Renamed => {
54 let old = delta
55 .old_file()
56 .path()
57 .unwrap()
58 .to_string_lossy()
59 .replace('\\', "/");
60 DiffStatus::Renamed { old_path: old }
61 }
62 _ => continue, };
64 let path = delta
65 .new_file()
66 .path()
67 .or_else(|| delta.old_file().path())
68 .unwrap()
69 .to_string_lossy()
70 .replace('\\', "/");
71 entries.push(DiffEntry { path, status });
72 }
73 Ok(entries)
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use tempfile::tempdir;
80
81 fn init_repo(dir: &Path) -> git2::Repository {
82 let repo = git2::Repository::init(dir).unwrap();
83 let mut config = repo.config().unwrap();
84 config.set_str("user.name", "Test").unwrap();
85 config.set_str("user.email", "test@test.com").unwrap();
86 repo
87 }
88
89 fn commit_file(repo: &git2::Repository, path: &str, content: &str, msg: &str) -> git2::Oid {
90 let root = repo.workdir().unwrap();
91 let file_path = root.join(path);
92 if let Some(parent) = file_path.parent() {
93 std::fs::create_dir_all(parent).unwrap();
94 }
95 std::fs::write(&file_path, content).unwrap();
96 let mut index = repo.index().unwrap();
97 index.add_path(Path::new(path)).unwrap();
98 index.write().unwrap();
99 let tree_oid = index.write_tree().unwrap();
100 let tree = repo.find_tree(tree_oid).unwrap();
101 let sig = repo.signature().unwrap();
102 let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
103 let parents: Vec<&git2::Commit> = parent.iter().collect();
104 repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents)
105 .unwrap()
106 }
107
108 #[test]
109 fn diff_tree_detects_added_file() {
110 let dir = tempdir().unwrap();
111 let repo = init_repo(dir.path());
112 let c1 = commit_file(&repo, "a.rs", "fn a() {}", "init");
113 let c2 = commit_file(&repo, "b.rs", "fn b() {}", "add b");
114 let entries = diff_tree_to_tree(&repo, &c1.to_string(), &c2.to_string()).unwrap();
115 assert_eq!(entries.len(), 1);
116 assert_eq!(entries[0].path, "b.rs");
117 assert!(matches!(entries[0].status, DiffStatus::Added));
118 }
119
120 #[test]
121 fn diff_tree_detects_modified_file() {
122 let dir = tempdir().unwrap();
123 let repo = init_repo(dir.path());
124 let c1 = commit_file(&repo, "a.rs", "fn a() {}", "init");
125 let c2 = commit_file(&repo, "a.rs", "fn a() { 1 }", "modify a");
126 let entries = diff_tree_to_tree(&repo, &c1.to_string(), &c2.to_string()).unwrap();
127 assert_eq!(entries.len(), 1);
128 assert_eq!(entries[0].path, "a.rs");
129 assert!(matches!(entries[0].status, DiffStatus::Modified));
130 }
131
132 #[test]
133 fn diff_tree_detects_deleted_file() {
134 let dir = tempdir().unwrap();
135 let repo = init_repo(dir.path());
136 let c1 = commit_file(&repo, "a.rs", "fn a() {}", "init");
137 std::fs::remove_file(dir.path().join("a.rs")).unwrap();
139 let mut index = repo.index().unwrap();
140 index.remove_path(Path::new("a.rs")).unwrap();
141 index.write().unwrap();
142 let tree_oid = index.write_tree().unwrap();
143 let tree = repo.find_tree(tree_oid).unwrap();
144 let sig = repo.signature().unwrap();
145 let parent = repo.head().unwrap().peel_to_commit().unwrap();
146 let c2 = repo
147 .commit(Some("HEAD"), &sig, &sig, "del a", &tree, &[&parent])
148 .unwrap();
149 let entries = diff_tree_to_tree(&repo, &c1.to_string(), &c2.to_string()).unwrap();
150 assert_eq!(entries.len(), 1);
151 assert_eq!(entries[0].path, "a.rs");
152 assert!(matches!(entries[0].status, DiffStatus::Deleted));
153 }
154
155 #[test]
156 fn diff_tree_returns_empty_for_same_commit() {
157 let dir = tempdir().unwrap();
158 let repo = init_repo(dir.path());
159 let c1 = commit_file(&repo, "a.rs", "fn a() {}", "init");
160 let entries = diff_tree_to_tree(&repo, &c1.to_string(), &c1.to_string()).unwrap();
161 assert!(entries.is_empty());
162 }
163}