1use crate::hash::{self, Hash};
15use crate::object::Object;
16use crate::store::{ObjectStore, StoreError};
17
18use super::merge::{self, Conflict};
19
20#[derive(Debug, thiserror::Error)]
22pub enum RevertError {
23 #[error("target hash does not refer to a commit object")]
24 NotACommit,
25 #[error("target commit's first parent does not refer to a commit object")]
26 ParentNotACommit,
27 #[error("cannot revert a merge commit ({0} parents); mainline selection is not yet supported")]
31 IsMergeCommit(usize),
32 #[error(transparent)]
33 Store(#[from] StoreError),
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RevertResult {
41 pub tree_hash: Hash,
42 pub conflicts: Vec<Conflict>,
43 pub message: Vec<u8>,
44}
45
46impl RevertResult {
47 #[must_use]
48 pub fn has_conflicts(&self) -> bool {
49 !self.conflicts.is_empty()
50 }
51}
52
53pub fn revert(
68 store: &ObjectStore,
69 target_hash: Hash,
70 ours_tree: Hash,
71) -> Result<RevertResult, RevertError> {
72 let Object::Commit(target_commit) = store.read_object(&target_hash)? else {
73 return Err(RevertError::NotACommit);
74 };
75
76 if target_commit.parents.len() > 1 {
80 return Err(RevertError::IsMergeCommit(target_commit.parents.len()));
81 }
82
83 let parent_tree: Option<Hash> = if target_commit.parents.is_empty() {
84 None
85 } else {
86 let Object::Commit(parent_commit) = store.read_object(&target_commit.parents[0])? else {
87 return Err(RevertError::ParentNotACommit);
88 };
89 Some(parent_commit.tree_hash)
90 };
91
92 let merge_result = merge::merge_trees(
94 store,
95 Some(target_commit.tree_hash),
96 Some(ours_tree),
97 parent_tree,
98 )?;
99
100 Ok(RevertResult {
101 tree_hash: merge_result.tree_hash,
102 conflicts: merge_result.conflicts,
103 message: revert_message(&target_commit.message, &target_hash),
104 })
105}
106
107#[must_use]
110pub fn revert_message(original_message: &[u8], target: &Hash) -> Vec<u8> {
111 let text = String::from_utf8_lossy(original_message);
112 let subject = text.lines().next().unwrap_or("");
113 format!(
114 "Revert \"{subject}\"\n\nThis reverts commit {}.\n",
115 hash::to_hex(target)
116 )
117 .into_bytes()
118}
119
120#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::object::{Blob, Commit, EntryMode, Identity, Object, Tree, TreeEntry};
128 use crate::ops::merge::ConflictKind;
129 use crate::serialize;
130 use tempfile::TempDir;
131
132 fn store() -> (TempDir, ObjectStore) {
133 let d = TempDir::new().unwrap();
134 let s = ObjectStore::init(&crate::layout::RepoLayout::single(d.path())).unwrap();
135 (d, s)
136 }
137 fn put_blob(s: &ObjectStore, data: &[u8]) -> Hash {
138 s.write(
139 &serialize::serialize(&Object::Blob(Blob {
140 data: data.to_vec(),
141 }))
142 .unwrap(),
143 )
144 .unwrap()
145 }
146 fn make_tree(s: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
147 s.write(&serialize::serialize(&Object::Tree(Tree { entries })).unwrap())
148 .unwrap()
149 }
150 fn entry(name: &[u8], h: Hash) -> TreeEntry {
151 TreeEntry {
152 name: name.to_vec(),
153 mode: EntryMode::Blob,
154 object_hash: h,
155 }
156 }
157 fn make_commit(s: &ObjectStore, tree: Hash, parents: &[Hash], message: &str) -> Hash {
158 let c = Commit {
159 tree_hash: tree,
160 parents: parents.to_vec(),
161 author: Identity::ed25519([0; 32]),
162 signer: [0; 32],
163 message: message.as_bytes().to_vec(),
164 timestamp: message.len() as u64,
165 message_hash: [0; 32],
166 content_digest: [0; 32],
167 signature: [0; 64],
168 };
169 s.write(&serialize::serialize(&Object::Commit(c)).unwrap())
170 .unwrap()
171 }
172 fn tree_names(s: &ObjectStore, h: Hash) -> Vec<Vec<u8>> {
173 match s.read_object(&h).unwrap() {
174 Object::Tree(t) => t.entries.into_iter().map(|e| e.name).collect(),
175 other => panic!("expected tree, got {other}"),
176 }
177 }
178
179 #[test]
180 fn reverting_an_add_removes_the_file() {
181 let (_d, s) = store();
182 let blob_a = put_blob(&s, b"aaa");
183 let base_tree = make_tree(&s, vec![entry(b"a.txt", blob_a)]);
184 let base = make_commit(&s, base_tree, &[], "initial");
185 let blob_b = put_blob(&s, b"bbb");
186 let target_tree = make_tree(&s, vec![entry(b"a.txt", blob_a), entry(b"b.txt", blob_b)]);
187 let target = make_commit(&s, target_tree, &[base], "add b.txt");
188
189 let r = revert(&s, target, target_tree).unwrap();
191 assert!(!r.has_conflicts());
192 assert_eq!(tree_names(&s, r.tree_hash), vec![b"a.txt".to_vec()]);
193 let msg = String::from_utf8(r.message).unwrap();
194 assert!(msg.starts_with("Revert \"add b.txt\""), "{msg}");
195 assert!(msg.contains("This reverts commit "), "{msg}");
196 }
197
198 #[test]
199 fn reverting_a_delete_restores_the_file() {
200 let (_d, s) = store();
201 let blob_a = put_blob(&s, b"aaa");
202 let base_tree = make_tree(&s, vec![entry(b"a.txt", blob_a)]);
203 let base = make_commit(&s, base_tree, &[], "initial");
204 let target_tree = make_tree(&s, vec![]); let target = make_commit(&s, target_tree, &[base], "remove a.txt");
206
207 let r = revert(&s, target, target_tree).unwrap();
208 assert!(!r.has_conflicts());
209 assert_eq!(tree_names(&s, r.tree_hash), vec![b"a.txt".to_vec()]);
210 }
211
212 #[test]
213 fn revert_conflicts_when_ours_changed_the_same_file() {
214 let (_d, s) = store();
215 let blob_orig = put_blob(&s, b"original");
216 let base_tree = make_tree(&s, vec![entry(b"a.txt", blob_orig)]);
217 let base = make_commit(&s, base_tree, &[], "initial");
218 let blob_target = put_blob(&s, b"target-change");
219 let target_tree = make_tree(&s, vec![entry(b"a.txt", blob_target)]);
220 let target = make_commit(&s, target_tree, &[base], "change a.txt");
221 let blob_ours = put_blob(&s, b"ours-change");
223 let ours_tree = make_tree(&s, vec![entry(b"a.txt", blob_ours)]);
224
225 let r = revert(&s, target, ours_tree).unwrap();
226 assert!(r.has_conflicts());
227 assert_eq!(r.conflicts[0].kind, ConflictKind::ModifyModify);
228 assert_eq!(r.conflicts[0].path, "a.txt");
229 }
230
231 #[test]
232 fn non_commit_input_errors() {
233 let (_d, s) = store();
234 let blob = put_blob(&s, b"x");
235 let empty = make_tree(&s, vec![]);
236 assert!(matches!(
237 revert(&s, blob, empty),
238 Err(RevertError::NotACommit)
239 ));
240 }
241
242 #[test]
243 fn reverting_a_merge_commit_is_refused() {
244 let (_d, s) = store();
245 let blob = put_blob(&s, b"x");
246 let t = make_tree(&s, vec![entry(b"a.txt", blob)]);
247 let p1 = make_commit(&s, t, &[], "p1");
248 let p2 = make_commit(&s, t, &[], "p2");
249 let merge = make_commit(&s, t, &[p1, p2], "merge");
250 assert!(matches!(
251 revert(&s, merge, t),
252 Err(RevertError::IsMergeCommit(2))
253 ));
254 }
255}