1use 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#[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
28pub 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 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 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 let commit_diff = diff::diff(obj_store, commits_table, &parent_id, commit_id)?;
65
66 checkout::checkout(obj_store, commits_table, head_commit_id)?;
68
69 for entry in &commit_diff.added {
75 let ns = Namespace::from_str_loose(&entry.namespace).unwrap_or(Namespace::World);
78 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 let _ = obj_store.store.delete(id);
117 }
118 }
119
120 for entry in &commit_diff.removed {
122 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 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 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 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 let revert_id =
214 revert(&mut obj, &mut commits, &cb.commit_id, &cb.commit_id, "DGX").unwrap();
215
216 assert_eq!(obj.store.len(), 1);
218
219 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 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 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 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 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 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}