Skip to main content

nusy_arrow_git/
revert.rs

1//! Revert — create a new commit that undoes the changes from a target commit.
2//!
3//! Unlike `checkout`, revert does not go back in time — it creates a NEW commit
4//! on the current branch that applies the inverse of the target commit's diff.
5
6use crate::checkout;
7use crate::commit::{CommitError, CommitsTable, create_commit};
8use crate::diff;
9use crate::object_store::GitObjectStore;
10use nusy_arrow_core::{Namespace, Triple, YLayer, col};
11
12/// Errors from revert operations.
13#[derive(Debug, thiserror::Error)]
14pub enum RevertError {
15    #[error("Commit error: {0}")]
16    Commit(#[from] CommitError),
17
18    #[error("Store error: {0}")]
19    Store(#[from] nusy_arrow_core::StoreError),
20
21    #[error("Cannot revert merge commit {0} (has {1} parents) — specify parent")]
22    MergeCommit(String, usize),
23
24    #[error("Commit has no parent: {0}")]
25    NoParent(String),
26}
27
28/// Revert a commit by creating a new commit that undoes its changes.
29///
30/// 1. Find the target commit's parent
31/// 2. Diff parent → target to get what the commit changed
32/// 3. Apply the inverse (add removed triples, remove added triples) to HEAD
33/// 4. Create a new commit with the inverted changes
34///
35/// Returns the new revert commit's ID.
36pub fn revert(
37    obj_store: &mut GitObjectStore,
38    commits_table: &mut CommitsTable,
39    commit_id: &str,
40    head_commit_id: &str,
41    author: &str,
42) -> Result<String, RevertError> {
43    let target = commits_table
44        .get(commit_id)
45        .ok_or_else(|| CommitError::NotFound(commit_id.to_string()))?;
46
47    // Cannot revert merge commits (multiple parents)
48    if target.parent_ids.len() > 1 {
49        return Err(RevertError::MergeCommit(
50            commit_id.to_string(),
51            target.parent_ids.len(),
52        ));
53    }
54
55    // Must have a parent to compute the diff
56    if target.parent_ids.is_empty() {
57        return Err(RevertError::NoParent(commit_id.to_string()));
58    }
59
60    let parent_id = target.parent_ids[0].clone();
61    let target_message = target.message.clone();
62
63    // Compute what the target commit changed: diff parent → target
64    let commit_diff = diff::diff(obj_store, commits_table, &parent_id, commit_id)?;
65
66    // Restore HEAD state
67    checkout::checkout(obj_store, commits_table, head_commit_id)?;
68
69    // Apply the INVERSE:
70    // - What was added by the commit should be removed
71    // - What was removed by the commit should be re-added
72
73    // Remove the added triples
74    for entry in &commit_diff.added {
75        // Defensive: fall back to World namespace if the diff entry has an
76        // unrecognized namespace string (schema evolution).
77        let ns = Namespace::from_str_loose(&entry.namespace).unwrap_or(Namespace::World);
78        // Find and delete matching triples in the store
79        let batches = obj_store.store.get_namespace_batches(ns);
80        let mut ids_to_delete = Vec::new();
81        for batch in batches {
82            let id_col = batch
83                .column(col::TRIPLE_ID)
84                .as_any()
85                .downcast_ref::<arrow::array::StringArray>()
86                .expect("triple_id column");
87            let subj_col = batch
88                .column(col::SUBJECT)
89                .as_any()
90                .downcast_ref::<arrow::array::StringArray>()
91                .expect("subject column");
92            let pred_col = batch
93                .column(col::PREDICATE)
94                .as_any()
95                .downcast_ref::<arrow::array::StringArray>()
96                .expect("predicate column");
97            let obj_col = batch
98                .column(col::OBJECT)
99                .as_any()
100                .downcast_ref::<arrow::array::StringArray>()
101                .expect("object column");
102
103            for i in 0..batch.num_rows() {
104                if subj_col.value(i) == entry.subject
105                    && pred_col.value(i) == entry.predicate
106                    && obj_col.value(i) == entry.object
107                {
108                    ids_to_delete.push(id_col.value(i).to_string());
109                }
110            }
111        }
112        for id in &ids_to_delete {
113            // Best-effort delete: triple may not exist in HEAD if state
114            // diverged since the original commit. Swallowing the error is
115            // intentional — the diff was computed against a different commit.
116            let _ = obj_store.store.delete(id);
117        }
118    }
119
120    // Re-add the removed triples
121    for entry in &commit_diff.removed {
122        // Defensive fallbacks: if namespace or y_layer values from the diff
123        // are unrecognized (e.g., schema evolved), default to World/Semantic
124        // rather than failing the entire revert.
125        let ns = Namespace::from_str_loose(&entry.namespace).unwrap_or(Namespace::World);
126        let y_layer = YLayer::from_u8(entry.y_layer).unwrap_or(YLayer::Semantic);
127        let triple = Triple {
128            subject: entry.subject.clone(),
129            predicate: entry.predicate.clone(),
130            object: entry.object.clone(),
131            graph: entry.graph.clone(),
132            confidence: entry.confidence,
133            source_document: entry.source_document.clone(),
134            source_chunk_id: entry.source_chunk_id.clone(),
135            extracted_by: Some(format!("revert by {author}")),
136            caused_by: entry.caused_by.clone(),
137            derived_from: entry.derived_from.clone(),
138            consolidated_at: entry.consolidated_at,
139            certifiability_class: entry.certifiability_class.clone(),
140        };
141        obj_store.store.add_triple(&triple, ns, y_layer)?;
142    }
143
144    // Create the revert commit
145    let revert_commit = create_commit(
146        obj_store,
147        commits_table,
148        vec![head_commit_id.to_string()],
149        &format!("Revert: {target_message}"),
150        author,
151    )?;
152
153    Ok(revert_commit.commit_id)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::commit::create_commit;
160    use nusy_arrow_core::{Namespace, Triple, YLayer};
161
162    fn sample_triple(subj: &str, obj: &str) -> Triple {
163        Triple {
164            subject: subj.to_string(),
165            predicate: "rdf:type".to_string(),
166            object: obj.to_string(),
167            graph: None,
168            confidence: Some(0.9),
169            source_document: None,
170            source_chunk_id: None,
171            extracted_by: None,
172            caused_by: None,
173            derived_from: None,
174            consolidated_at: None,
175            certifiability_class: None,
176        }
177    }
178
179    #[test]
180    fn test_revert_restores_previous_state() {
181        let tmp = tempfile::tempdir().unwrap();
182        let mut obj = GitObjectStore::with_snapshot_dir(tmp.path());
183        let mut commits = CommitsTable::new();
184
185        // Commit A: one triple
186        obj.store
187            .add_triple(
188                &sample_triple("s1", "A"),
189                Namespace::World,
190                YLayer::Semantic,
191            )
192            .unwrap();
193        let ca = create_commit(&obj, &mut commits, vec![], "commit A", "DGX").unwrap();
194
195        // Commit B: add another triple
196        obj.store
197            .add_triple(
198                &sample_triple("s2", "B"),
199                Namespace::World,
200                YLayer::Semantic,
201            )
202            .unwrap();
203        let cb = create_commit(
204            &obj,
205            &mut commits,
206            vec![ca.commit_id.clone()],
207            "commit B",
208            "DGX",
209        )
210        .unwrap();
211
212        // Revert B — should undo the addition of s2
213        let revert_id =
214            revert(&mut obj, &mut commits, &cb.commit_id, &cb.commit_id, "DGX").unwrap();
215
216        // After revert, only s1 should exist
217        assert_eq!(obj.store.len(), 1);
218
219        // Verify revert commit exists and has correct message
220        let rc = commits.get(&revert_id).unwrap();
221        assert!(rc.message.starts_with("Revert:"));
222        assert_eq!(rc.parent_ids, vec![cb.commit_id.clone()]);
223    }
224
225    #[test]
226    fn test_revert_of_revert_restores_original() {
227        let tmp = tempfile::tempdir().unwrap();
228        let mut obj = GitObjectStore::with_snapshot_dir(tmp.path());
229        let mut commits = CommitsTable::new();
230
231        // Commit A: one triple
232        obj.store
233            .add_triple(
234                &sample_triple("s1", "A"),
235                Namespace::World,
236                YLayer::Semantic,
237            )
238            .unwrap();
239        let ca = create_commit(&obj, &mut commits, vec![], "commit A", "DGX").unwrap();
240
241        // Commit B: add s2
242        obj.store
243            .add_triple(
244                &sample_triple("s2", "B"),
245                Namespace::World,
246                YLayer::Semantic,
247            )
248            .unwrap();
249        let cb = create_commit(
250            &obj,
251            &mut commits,
252            vec![ca.commit_id.clone()],
253            "commit B",
254            "DGX",
255        )
256        .unwrap();
257
258        // Revert B
259        let revert_id =
260            revert(&mut obj, &mut commits, &cb.commit_id, &cb.commit_id, "DGX").unwrap();
261        assert_eq!(obj.store.len(), 1);
262
263        // Revert the revert — should restore s2
264        let _revert2_id = revert(&mut obj, &mut commits, &revert_id, &revert_id, "DGX").unwrap();
265        assert_eq!(obj.store.len(), 2);
266    }
267
268    #[test]
269    fn test_revert_merge_commit_errors() {
270        let tmp = tempfile::tempdir().unwrap();
271        let mut obj = GitObjectStore::with_snapshot_dir(tmp.path());
272        let mut commits = CommitsTable::new();
273
274        // Create a fake merge commit with 2 parents
275        obj.store
276            .add_triple(
277                &sample_triple("s1", "A"),
278                Namespace::World,
279                YLayer::Semantic,
280            )
281            .unwrap();
282        let c1 = create_commit(&obj, &mut commits, vec![], "c1", "DGX").unwrap();
283        let c2 =
284            create_commit(&obj, &mut commits, vec![c1.commit_id.clone()], "c2", "DGX").unwrap();
285        let merge = create_commit(
286            &obj,
287            &mut commits,
288            vec![c1.commit_id.clone(), c2.commit_id.clone()],
289            "merge",
290            "DGX",
291        )
292        .unwrap();
293
294        let result = revert(
295            &mut obj,
296            &mut commits,
297            &merge.commit_id,
298            &merge.commit_id,
299            "DGX",
300        );
301        assert!(result.is_err());
302        match result.unwrap_err() {
303            RevertError::MergeCommit(_, n) => assert_eq!(n, 2),
304            other => panic!("Expected MergeCommit error, got: {other:?}"),
305        }
306    }
307
308    #[test]
309    fn test_revert_root_commit_errors() {
310        let tmp = tempfile::tempdir().unwrap();
311        let mut obj = GitObjectStore::with_snapshot_dir(tmp.path());
312        let mut commits = CommitsTable::new();
313
314        obj.store
315            .add_triple(
316                &sample_triple("s1", "A"),
317                Namespace::World,
318                YLayer::Semantic,
319            )
320            .unwrap();
321        let c1 = create_commit(&obj, &mut commits, vec![], "root", "DGX").unwrap();
322
323        let result = revert(&mut obj, &mut commits, &c1.commit_id, &c1.commit_id, "DGX");
324        assert!(result.is_err());
325        match result.unwrap_err() {
326            RevertError::NoParent(_) => {}
327            other => panic!("Expected NoParent error, got: {other:?}"),
328        }
329    }
330}