1use crate::changeset::Changeset;
25use crate::error::{Error, Result};
26use crate::repo::VaultRepo;
27use git2::Oid;
28use std::path::Path;
29use tracing::instrument;
30
31impl VaultRepo {
32 pub fn read_at(&self, commit: Oid, path: &str) -> Result<Option<Vec<u8>>> {
35 let tree = self.git().find_commit(commit)?.tree()?;
36 match tree.get_path(Path::new(path)) {
37 Ok(entry) => Ok(Some(self.read_blob(entry.id())?)),
38 Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
39 Err(e) => Err(Error::Git(e)),
40 }
41 }
42
43 pub fn paths_changed_between(&self, a: Oid, b: Oid) -> Result<Vec<String>> {
47 let r = self.git();
48 let a_tree = r.find_commit(a)?.tree()?;
49 let b_tree = r.find_commit(b)?.tree()?;
50 let diff = r.diff_tree_to_tree(Some(&a_tree), Some(&b_tree), None)?;
51 let mut paths = Vec::new();
52 diff.foreach(
53 &mut |delta, _| {
54 if let Some(p) = delta.new_file().path().or_else(|| delta.old_file().path()) {
55 paths.push(p.to_string_lossy().to_string());
56 }
57 true
58 },
59 None,
60 None,
61 None,
62 )?;
63 Ok(paths)
64 }
65
66 pub fn diff_path_statuses(&self, a: Option<Oid>, b: Oid) -> Result<Vec<(String, bool)>> {
78 let r = self.git();
79 let b_tree = r.find_commit(b)?.tree()?;
80 let a_tree = match a {
81 Some(oid) => Some(r.find_commit(oid)?.tree()?),
82 None => None,
83 };
84 let diff = r.diff_tree_to_tree(a_tree.as_ref(), Some(&b_tree), None)?;
85
86 let mut out = Vec::new();
87 diff.foreach(
88 &mut |delta, _| {
89 let status = delta.status();
90 let path = match status {
92 git2::Delta::Deleted => delta.old_file().path(),
93 _ => delta.new_file().path().or_else(|| delta.old_file().path()),
94 };
95 if let Some(p) = path {
96 let present_in_b = !matches!(status, git2::Delta::Deleted);
97 out.push((p.to_string_lossy().to_string(), present_in_b));
98 }
99 true
100 },
101 None,
102 None,
103 None,
104 )?;
105 Ok(out)
106 }
107
108 #[instrument(
122 skip(self, paths, message),
123 fields(target_commit = %target_commit, n_paths = paths.len()),
124 name = "git_build_restore_changeset"
125 )]
126 pub fn build_restore_changeset(
127 &self,
128 target_commit: Oid,
129 paths: &[String],
130 message: impl Into<String>,
131 ) -> Result<Option<Changeset>> {
132 let head_oid = self
133 .head_oid()
134 .ok_or_else(|| Error::Other("cannot restore: branch is unborn".to_string()))?;
135 let head_tree = self.git().find_commit(head_oid)?.tree_id();
136 let target_tree = self.git().find_commit(target_commit)?.tree_id();
137
138 let mut txn = Changeset::new(message);
139 let mut any = false;
140 for path in paths {
141 let current = self.blob_oid_at(head_tree, path)?;
142 let target = self.blob_oid_at(target_tree, path)?;
143 if current == target {
144 continue; }
146 match (current, target) {
147 (Some(current_oid), Some(target_oid)) => {
148 let content = self.read_blob(target_oid)?;
149 txn = txn.update(path, content, current_oid);
150 }
151 (Some(current_oid), None) => {
152 txn = txn.delete(path, current_oid);
153 }
154 (None, Some(target_oid)) => {
155 let content = self.read_blob(target_oid)?;
156 txn = txn.create(path, content);
157 }
158 (None, None) => unreachable!("filtered by current == target above"),
159 }
160 any = true;
161 }
162 Ok(if any { Some(txn) } else { None })
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use git2::Repository;
170 use tempfile::TempDir;
171
172 fn open_unborn() -> (TempDir, VaultRepo) {
173 let tmp = TempDir::new().unwrap();
174 let mut opts = git2::RepositoryInitOptions::new();
175 opts.initial_head("main");
176 Repository::init_opts(tmp.path(), &opts).unwrap();
177 let vr = VaultRepo::open(tmp.path()).unwrap();
178 (tmp, vr)
179 }
180
181 fn workfile(vr: &VaultRepo, rel: &str) -> std::path::PathBuf {
182 vr.git().workdir().unwrap().join(rel)
183 }
184
185 fn read_wt(vr: &VaultRepo, rel: &str) -> String {
186 std::fs::read_to_string(workfile(vr, rel)).unwrap()
187 }
188
189 fn commit(vr: &VaultRepo, txn: Changeset) -> Oid {
191 vr.commit_changeset(&txn).unwrap().commit
192 }
193
194 #[test]
195 fn read_at_returns_content_or_none() {
196 let (_t, vr) = open_unborn();
197 let c1 = commit(&vr, Changeset::new("c").create("a.md", "v1"));
198 assert_eq!(
199 vr.read_at(c1, "a.md").unwrap().as_deref(),
200 Some(b"v1".as_slice())
201 );
202 assert_eq!(vr.read_at(c1, "missing.md").unwrap(), None);
203 }
204
205 #[test]
206 fn paths_changed_between_diff_two_commits() {
207 let (_t, vr) = open_unborn();
208 let c1 = commit(&vr, Changeset::new("c").create("a.md", "alpha"));
209 let blob_a = VaultRepo::blob_oid_of(b"alpha").unwrap();
210 let c2 = commit(
211 &vr,
212 Changeset::new("c2")
213 .update("a.md", "ALPHA", blob_a)
214 .create("b.md", "beta"),
215 );
216 let mut paths = vr.paths_changed_between(c1, c2).unwrap();
217 paths.sort();
218 assert_eq!(paths, vec!["a.md".to_string(), "b.md".to_string()]);
219 }
220
221 #[test]
222 fn restore_updates_a_changed_path_back() {
223 let (_t, vr) = open_unborn();
225 let c1 = commit(&vr, Changeset::new("c").create("a.md", "v1"));
226 let blob_v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
227 let _c2 = commit(&vr, Changeset::new("u").update("a.md", "v2", blob_v1));
228
229 let restore_txn = vr
230 .build_restore_changeset(c1, &["a.md".to_string()], "rollback to c1")
231 .unwrap()
232 .expect("there IS something to restore");
233 vr.commit_changeset(&restore_txn).unwrap();
234 assert_eq!(read_wt(&vr, "a.md"), "v1", "restored to c1's content");
235 }
236
237 #[test]
238 fn restore_recreates_a_deleted_path() {
239 let (_t, vr) = open_unborn();
241 let c1 = commit(&vr, Changeset::new("c").create("a.md", "v1"));
242 let blob_v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
243 let _c2 = commit(&vr, Changeset::new("d").delete("a.md", blob_v1));
244 assert!(!workfile(&vr, "a.md").exists());
245
246 let restore_txn = vr
247 .build_restore_changeset(c1, &["a.md".to_string()], "undo delete")
248 .unwrap()
249 .unwrap();
250 vr.commit_changeset(&restore_txn).unwrap();
251 assert_eq!(read_wt(&vr, "a.md"), "v1");
252 }
253
254 #[test]
255 fn restore_deletes_a_created_path() {
256 let (_t, vr) = open_unborn();
258 let c1 = commit(&vr, Changeset::new("seed").create("seed.md", "S"));
260 let _c2 = commit(&vr, Changeset::new("c").create("a.md", "alpha"));
261 assert!(workfile(&vr, "a.md").exists());
262
263 let restore_txn = vr
264 .build_restore_changeset(c1, &["a.md".to_string()], "undo create")
265 .unwrap()
266 .unwrap();
267 vr.commit_changeset(&restore_txn).unwrap();
268 assert!(
269 !workfile(&vr, "a.md").exists(),
270 "a.md absent in target, removed"
271 );
272 }
273
274 #[test]
275 fn restore_no_op_when_current_matches_target() {
276 let (_t, vr) = open_unborn();
277 let c1 = commit(&vr, Changeset::new("c").create("a.md", "v1"));
278 let result = vr
280 .build_restore_changeset(c1, &["a.md".to_string()], "nothing to do")
281 .unwrap();
282 assert!(result.is_none(), "no-op restore returns None");
283 }
284
285 #[test]
286 fn restore_full_commit_undoes_its_changes() {
287 let (_t, vr) = open_unborn();
290 let c1 = commit(
291 &vr,
292 Changeset::new("seed")
293 .create("a.md", "A1")
294 .create("b.md", "B1"),
295 );
296 let blob_a1 = VaultRepo::blob_oid_of(b"A1").unwrap();
297 let blob_b1 = VaultRepo::blob_oid_of(b"B1").unwrap();
298 let c2 = commit(
299 &vr,
300 Changeset::new("multi")
301 .update("a.md", "A2", blob_a1)
302 .update("b.md", "B2", blob_b1),
303 );
304
305 let paths = vr.paths_changed_between(c1, c2).unwrap();
307 let restore_txn = vr
308 .build_restore_changeset(c1, &paths, "rollback c2")
309 .unwrap()
310 .unwrap();
311 vr.commit_changeset(&restore_txn).unwrap();
312 assert_eq!(read_wt(&vr, "a.md"), "A1");
313 assert_eq!(read_wt(&vr, "b.md"), "B1");
314 }
315
316 #[test]
317 fn restore_aborts_loudly_if_path_changed_since_request() {
318 let (_t, vr) = open_unborn();
322 let c1 = commit(&vr, Changeset::new("c").create("a.md", "v1"));
323 let blob_v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
324 let _c2 = commit(&vr, Changeset::new("u").update("a.md", "v2", blob_v1));
325
326 let restore_txn = vr
328 .build_restore_changeset(c1, &["a.md".to_string()], "rollback to c1")
329 .unwrap()
330 .unwrap();
331 let blob_v2 = VaultRepo::blob_oid_of(b"v2").unwrap();
333 commit(&vr, Changeset::new("u2").update("a.md", "v3", blob_v2));
334 let res = vr.commit_changeset(&restore_txn);
336 assert!(matches!(res, Err(Error::PreconditionFailed { path, .. }) if path == "a.md"));
337 assert_eq!(read_wt(&vr, "a.md"), "v3", "concurrent change preserved");
338 }
339
340 #[test]
343 fn diff_path_statuses_initial_commit_treats_everything_as_added() {
344 let (_t, vr) = open_unborn();
345 let c = commit(
346 &vr,
347 Changeset::new("init")
348 .create("a.md", "A")
349 .create("dir/b.md", "B"),
350 );
351 let mut out = vr.diff_path_statuses(None, c).unwrap();
352 out.sort();
353 assert_eq!(
354 out,
355 vec![("a.md".to_string(), true), ("dir/b.md".to_string(), true)],
356 );
357 }
358
359 #[test]
360 fn diff_path_statuses_distinguishes_added_modified_deleted() {
361 let (_t, vr) = open_unborn();
362 let c1 = commit(
363 &vr,
364 Changeset::new("seed")
365 .create("keep.md", "K")
366 .create("gone.md", "G")
367 .create("mod.md", "M1"),
368 );
369 let m1 = VaultRepo::blob_oid_of(b"M1").unwrap();
370 let g = VaultRepo::blob_oid_of(b"G").unwrap();
371 let c2 = commit(
372 &vr,
373 Changeset::new("mix")
374 .create("new.md", "N")
375 .update("mod.md", "M2", m1)
376 .delete("gone.md", g),
377 );
378
379 let mut out = vr.diff_path_statuses(Some(c1), c2).unwrap();
380 out.sort();
381 assert_eq!(
382 out,
383 vec![
384 ("gone.md".to_string(), false), ("mod.md".to_string(), true), ("new.md".to_string(), true), ],
388 "keep.md (unchanged) is NOT in the diff"
389 );
390 }
391
392 #[test]
393 fn diff_path_statuses_empty_for_identical_commits() {
394 let (_t, vr) = open_unborn();
395 let c = commit(&vr, Changeset::new("c").create("a.md", "x"));
396 let out = vr.diff_path_statuses(Some(c), c).unwrap();
397 assert!(out.is_empty());
398 }
399}