gonzalo_store_git/
diff.rs1use gonzalo_core::{CoreError, Result};
12use std::path::Path;
13
14#[derive(Clone, Debug, Default, PartialEq, Eq)]
18pub struct ChangedPaths {
19 pub added: Vec<String>,
22 pub modified: Vec<String>,
24 pub deleted: Vec<String>,
26}
27
28impl ChangedPaths {
29 pub fn is_empty(&self) -> bool {
31 self.added.is_empty() && self.modified.is_empty() && self.deleted.is_empty()
32 }
33}
34
35pub fn is_git_repo(path: &Path) -> bool {
37 git2::Repository::discover(path).is_ok()
38}
39
40pub fn head_commit(root: &Path) -> Result<String> {
44 let repo = git2::Repository::open(root).map_err(|e| CoreError::Backend(e.to_string()))?;
45 let head = repo.head().map_err(|e| CoreError::Backend(e.to_string()))?;
46 let oid = head
47 .target()
48 .ok_or_else(|| CoreError::Backend("HEAD has no target commit".into()))?;
49 Ok(oid.to_string())
50}
51
52pub fn changed_paths(root: &Path, base: &str) -> Result<ChangedPaths> {
58 let repo = git2::Repository::open(root).map_err(|e| CoreError::Backend(e.to_string()))?;
59 let base_oid =
60 git2::Oid::from_str(base).map_err(|e| CoreError::Backend(format!("bad base oid: {e}")))?;
61 let base_tree = repo
62 .find_commit(base_oid)
63 .map_err(|e| CoreError::Backend(e.to_string()))?
64 .tree()
65 .map_err(|e| CoreError::Backend(e.to_string()))?;
66
67 let mut opts = git2::DiffOptions::new();
68 opts.include_untracked(true).recurse_untracked_dirs(true);
69 let diff = repo
70 .diff_tree_to_workdir_with_index(Some(&base_tree), Some(&mut opts))
71 .map_err(|e| CoreError::Backend(e.to_string()))?;
72
73 let mut changed = ChangedPaths::default();
74 for delta in diff.deltas() {
75 match delta.status() {
76 git2::Delta::Added | git2::Delta::Untracked | git2::Delta::Copied => {
77 if let Some(p) = path_str(delta.new_file().path()) {
78 changed.added.push(p);
79 }
80 }
81 git2::Delta::Deleted => {
82 if let Some(p) = path_str(delta.old_file().path()) {
83 changed.deleted.push(p);
84 }
85 }
86 git2::Delta::Modified | git2::Delta::Typechange => {
87 if let Some(p) = path_str(delta.new_file().path()) {
88 changed.modified.push(p);
89 }
90 }
91 git2::Delta::Renamed => {
92 if let Some(p) = path_str(delta.old_file().path()) {
93 changed.deleted.push(p);
94 }
95 if let Some(p) = path_str(delta.new_file().path()) {
96 changed.added.push(p);
97 }
98 }
99 _ => {}
100 }
101 }
102 changed.added.sort();
103 changed.added.dedup();
104 changed.modified.sort();
105 changed.modified.dedup();
106 changed.deleted.sort();
107 changed.deleted.dedup();
108 Ok(changed)
109}
110
111fn path_str(path: Option<&Path>) -> Option<String> {
113 path.map(|p| p.to_string_lossy().replace('\\', "/"))
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use std::path::Path;
120 use tempfile::TempDir;
121
122 fn init_repo_with(dir: &Path, files: &[(&str, &str)]) -> String {
124 let repo = git2::Repository::init(dir).unwrap();
125 for (name, contents) in files {
126 let path = dir.join(name);
127 if let Some(parent) = path.parent() {
128 std::fs::create_dir_all(parent).unwrap();
129 }
130 std::fs::write(&path, contents).unwrap();
131 }
132 commit_all(&repo, "initial")
133 }
134
135 fn commit_all(repo: &git2::Repository, msg: &str) -> String {
136 let mut index = repo.index().unwrap();
137 index
138 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
139 .unwrap();
140 index.write().unwrap();
141 let tree_oid = index.write_tree().unwrap();
142 let tree = repo.find_tree(tree_oid).unwrap();
143 let sig = git2::Signature::now("t", "t@localhost").unwrap();
144 let parent = repo
145 .head()
146 .ok()
147 .and_then(|h| h.target())
148 .and_then(|oid| repo.find_commit(oid).ok());
149 let parents: Vec<&git2::Commit> = parent.iter().collect();
150 let oid = repo
151 .commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents)
152 .unwrap();
153 oid.to_string()
154 }
155
156 #[test]
157 fn is_git_repo_detects_repo() {
158 let dir = TempDir::new().unwrap();
159 assert!(!is_git_repo(dir.path()), "empty dir is not a repo");
160 git2::Repository::init(dir.path()).unwrap();
161 assert!(is_git_repo(dir.path()), "initialized dir is a repo");
162 }
163
164 #[test]
165 fn head_commit_resolves_to_the_last_commit() {
166 let dir = TempDir::new().unwrap();
167 let sha = init_repo_with(dir.path(), &[("a.rs", "fn a() {}")]);
168 assert_eq!(head_commit(dir.path()).unwrap(), sha);
169 }
170
171 #[test]
172 fn changed_paths_reports_added_modified_deleted() {
173 let dir = TempDir::new().unwrap();
174 let base = init_repo_with(dir.path(), &[("a.rs", "fn a() {}"), ("b.rs", "fn b() {}")]);
175
176 std::fs::write(dir.path().join("a.rs"), "fn a() { x(); }").unwrap();
178 std::fs::remove_file(dir.path().join("b.rs")).unwrap();
179 std::fs::write(dir.path().join("c.rs"), "fn c() {}").unwrap();
180
181 let changed = changed_paths(dir.path(), &base).unwrap();
182 assert_eq!(changed.added, vec!["c.rs".to_string()]);
183 assert_eq!(changed.modified, vec!["a.rs".to_string()]);
184 assert_eq!(changed.deleted, vec!["b.rs".to_string()]);
185 assert!(!changed.is_empty());
186 }
187
188 #[test]
189 fn changed_paths_empty_when_working_tree_matches_base() {
190 let dir = TempDir::new().unwrap();
191 let base = init_repo_with(dir.path(), &[("a.rs", "fn a() {}")]);
192 let changed = changed_paths(dir.path(), &base).unwrap();
193 assert!(changed.is_empty(), "clean tree has no changes: {changed:?}");
194 }
195
196 #[test]
197 fn changed_paths_finds_nested_untracked_files() {
198 let dir = TempDir::new().unwrap();
199 let base = init_repo_with(dir.path(), &[("a.rs", "fn a() {}")]);
200 std::fs::create_dir_all(dir.path().join("sub")).unwrap();
201 std::fs::write(dir.path().join("sub/deep.rs"), "fn d() {}").unwrap();
202
203 let changed = changed_paths(dir.path(), &base).unwrap();
204 assert_eq!(changed.added, vec!["sub/deep.rs".to_string()]);
205 }
206}