Skip to main content

mkit_core/ops/
revert.rs

1//! Single-commit revert onto a base tree — the inverse of cherry-pick.
2//!
3//! Where [`cherry_pick`](super::cherry_pick()) applies `target`'s change to
4//! `ours` via the 3-way merge `(base = target.parent.tree, ours, theirs =
5//! target.tree)`, revert *undoes* it by swapping `base` and `theirs`:
6//! `(base = target.tree, ours, theirs = target.parent.tree)`. The result
7//! is the tree `ours` would have if `target`'s diff were reversed.
8//!
9//! Like cherry-pick this is a tree-level op: it returns the merged tree
10//! and any conflicts, plus a generated `Revert "..."` commit message.
11//! Building/committing/ref-wiring is the CLI's job. A revert is a normal
12//! *forward* commit, so it is not gated on the gc recovery model.
13
14use crate::hash::{self, Hash};
15use crate::object::Object;
16use crate::store::{ObjectStore, StoreError};
17
18use super::merge::{self, Conflict};
19
20/// Errors specific to revert on top of [`StoreError`].
21#[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    /// Reverting a merge commit is ambiguous — different parents yield
28    /// different inverse patches — and mainline selection (`-m`) is not
29    /// yet supported, so we refuse rather than silently pick parent 0.
30    #[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/// Result of [`revert`]. `tree_hash` is the merged tree (always written,
37/// even on conflict — it holds "ours" at every conflicting path).
38/// `message` is the generated `Revert "<subject>"` commit message.
39#[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
53/// Revert `target_hash` against `ours_tree`.
54///
55/// 1. Load the target commit. (Error if not a commit.)
56/// 2. Load the target's first parent's tree as the merge `theirs`. A
57///    root commit (no parents) reverts against the empty tree, removing
58///    everything it introduced.
59/// 3. 3-way merge `(base = target.tree, ours = ours_tree, theirs =
60///    parent.tree)` — the inverse of cherry-pick.
61/// 4. Return the merged tree, any conflicts, and a `Revert "..."` message.
62///
63/// # Errors
64/// * [`RevertError::NotACommit`] / [`RevertError::ParentNotACommit`] for
65///   bad input objects.
66/// * [`RevertError::Store`] for wrapped store/serialize errors.
67pub 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    // A merge commit has no single "the change it introduced": the
77    // inverse depends on which parent is the mainline. Refuse (fail
78    // closed) until `-m` is supported, rather than silently pick parent 0.
79    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    // Inverse of cherry-pick: base = target's tree, theirs = parent's tree.
93    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/// The git-style revert message: `Revert "<subject>"` + a trailer naming
108/// the reverted commit. `<subject>` is the first line of the original.
109#[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// =====================================================================
121// Tests
122// =====================================================================
123
124#[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        // Revert "add b.txt" against its own resulting tree → b.txt gone.
190        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![]); // a.txt removed
205        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        // ours diverged from target on the same path.
222        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}