Skip to main content

turbovault_git/
restore.rs

1//! Restore from git history (GWS.10).
2//!
3//! Git history *is* the rollback log — every prior version of every path is in
4//! the object DB, content-addressed. The substrate doesn't compensate after a
5//! partial apply (atomic-commit: the ref either advances or it doesn't; orphan
6//! blobs GC away), so "rollback" in this model is **forward**: build a new
7//! changeset that restores the affected paths to their state at some target
8//! commit, and apply it as a normal commit.
9//!
10//! Primitives:
11//! - [`VaultRepo::read_at`] — preview a path's content at a historical commit.
12//! - [`VaultRepo::paths_changed_between`] — the path set the rollback tool
13//!   needs (diff the commit-to-undo against its parent).
14//! - [`VaultRepo::build_restore_changeset`] — assemble a [`Changeset`]
15//!   that brings each given path back to its target-commit state, with the
16//!   right precondition (the path's CURRENT blob at HEAD) so a concurrent
17//!   change since the rollback was requested aborts loudly. Caller applies it
18//!   via [`VaultRepo::commit_changeset`].
19//!
20//! The tool layer's `rollback_note(operation_id)` composes these: locate the
21//! commit for `operation_id`, take its parent as the target, list the paths
22//! it touched, and apply the restore changeset.
23
24use 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    /// Read a path's bytes at a specific commit. `None` if the path is absent
33    /// in that commit's tree. The bytes-level preview for the rollback UI.
34    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    /// The set of paths whose content differs between commits `a` and `b`.
44    /// For the rollback flow, pass the commit-to-undo as `b` and its parent as
45    /// `a` to get exactly the paths to restore.
46    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    /// Per-path change status between two commits, or between the empty tree
67    /// and `b` when `a` is `None` (the initial-commit case).
68    ///
69    /// Each entry is `(path, present_in_b)`:
70    /// - `true`  → path was added or modified in `b` (re-index it).
71    /// - `false` → path was deleted in `b` (drop it from derived indexes).
72    ///
73    /// Used by the GWS.14 reindex apply step, which needs to distinguish
74    /// "added/modified → parse + add to graph" from "deleted → remove from
75    /// graph". `paths_changed_between` collapses both into one bag, which
76    /// loses the information.
77    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                // Pick the path that actually exists on the relevant side.
91                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    /// Build a changeset that restores `paths` to their state at
109    /// `target_commit`, with a precondition on each path's current blob at
110    /// HEAD (so a concurrent write since the restore was requested aborts the
111    /// whole thing loudly).
112    ///
113    /// For each path:
114    /// - target has it + current has a different version → `update`.
115    /// - target has it + current absent → `create`.
116    /// - target lacks it + current has it → `delete`.
117    /// - target == current → skipped (no-op).
118    ///
119    /// Returns `Ok(None)` when there is nothing to do (every path is already
120    /// at the target state). Errors if the branch is unborn.
121    #[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; // already at target state
145            }
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    /// Commit `txn` and return the new HEAD commit oid.
190    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        // Restore an updated file to its earlier content.
224        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        // The deleted-then-restored case: target has it, current does not -> create.
240        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        // The created-then-restored case: target lacks it, current has it -> delete.
257        let (_t, vr) = open_unborn();
258        // Make a non-empty initial commit so we have a target commit BEFORE a.md existed.
259        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        // Path already matches target -> Ok(None).
279        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        // The rollback_note flow: undo a commit by restoring every path it
288        // touched to its state at the commit's parent.
289        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        // To undo c2: restore the paths it touched to their state at its parent (c1).
306        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        // The reconsideration domino on the rollback path: if `a.md` is mutated
319        // between when the rollback was prepared and when it applies, the
320        // precondition (current blob) fails and the restore aborts.
321        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        // Prepare the restore txn (preconditioned against current state == v2).
327        let restore_txn = vr
328            .build_restore_changeset(c1, &["a.md".to_string()], "rollback to c1")
329            .unwrap()
330            .unwrap();
331        // Concurrent third write moves a.md to v3 before the restore applies.
332        let blob_v2 = VaultRepo::blob_oid_of(b"v2").unwrap();
333        commit(&vr, Changeset::new("u2").update("a.md", "v3", blob_v2));
334        // Now applying the prepared restore must abort — precondition expects v2.
335        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    // -------- GWS.14: diff_path_statuses --------
341
342    #[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), // deleted
385                ("mod.md".to_string(), true),   // modified
386                ("new.md".to_string(), true),   // added
387            ],
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}