Skip to main content

mkit_core/ops/
cherry_pick.rs

1//! Single-commit cherry-pick onto a different base tree.
2//!
3//! This is intentionally a *tree-level* operation: it computes the
4//! 3-way merge of `(target.parents[0].tree, ours_tree, target.tree)`
5//! and returns the resulting tree hash plus any conflicts. Building a
6//! new commit on top of the merged tree is the caller's job — the CLI
7//! layer wires refs and the index together.
8//!
9//! There is no `AlreadyAncestor` short-circuit here — that is a
10//! higher-level decision the CLI makes before calling cherry-pick.
11
12use crate::hash::Hash;
13use crate::object::Object;
14use crate::store::{ObjectStore, StoreError};
15
16use super::merge::{self, Conflict};
17
18/// Errors specific to cherry-pick on top of [`StoreError`]. We split
19/// these out so callers can distinguish "your input hash didn't point
20/// at a commit" (a programmer error) from filesystem failures.
21#[derive(Debug, thiserror::Error)]
22pub enum CherryPickError {
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(transparent)]
28    Store(#[from] StoreError),
29}
30
31/// Result of [`cherry_pick`]. `tree_hash` is the merged tree (always
32/// written to the store, even on conflict — the merged tree contains
33/// "ours" at every conflicting path). `original_message` is the target
34/// commit's message verbatim, so the caller can use it as the basis
35/// for a new commit.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CherryPickResult {
38    pub tree_hash: Hash,
39    pub conflicts: Vec<Conflict>,
40    pub original_message: Vec<u8>,
41}
42
43impl CherryPickResult {
44    #[must_use]
45    pub fn has_conflicts(&self) -> bool {
46        !self.conflicts.is_empty()
47    }
48}
49
50/// Cherry-pick `target_hash` onto `ours_tree`.
51///
52/// Algorithm:
53///
54/// 1. Load the target commit. (Error if not a commit.)
55/// 2. Load the target's first parent's tree as the merge `base`. If
56///    the target is a root commit (no parents), `base = None` (empty
57///    tree).
58/// 3. 3-way merge `(base, ours_tree, target.tree_hash)`.
59/// 4. Return the merged tree hash, any conflicts, and the target
60///    commit's `message` so the caller can craft a new commit.
61///
62/// # Errors
63///
64/// * [`CherryPickError::NotACommit`] when `target_hash` doesn't point
65///   at a commit object.
66/// * [`CherryPickError::ParentNotACommit`] when the parent hash points
67///   at something other than a commit.
68/// * [`CherryPickError::Store`] for any wrapped store/serialize error.
69pub fn cherry_pick(
70    store: &ObjectStore,
71    target_hash: Hash,
72    ours_tree: Hash,
73) -> Result<CherryPickResult, CherryPickError> {
74    let Object::Commit(target_commit) = store.read_object(&target_hash)? else {
75        return Err(CherryPickError::NotACommit);
76    };
77
78    let parent_tree: Option<Hash> = if target_commit.parents.is_empty() {
79        None
80    } else {
81        let Object::Commit(parent_commit) = store.read_object(&target_commit.parents[0])? else {
82            return Err(CherryPickError::ParentNotACommit);
83        };
84        Some(parent_commit.tree_hash)
85    };
86
87    let original_message = target_commit.message.clone();
88    let merge_result = merge::merge_trees(
89        store,
90        parent_tree,
91        Some(ours_tree),
92        Some(target_commit.tree_hash),
93    )?;
94
95    Ok(CherryPickResult {
96        tree_hash: merge_result.tree_hash,
97        conflicts: merge_result.conflicts,
98        original_message,
99    })
100}
101
102// =====================================================================
103// Tests
104// =====================================================================
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::object::{Blob, Commit, EntryMode, Identity, Object, Tree, TreeEntry};
110    use crate::ops::merge::ConflictKind;
111    use crate::serialize;
112    use tempfile::TempDir;
113
114    fn store() -> (TempDir, ObjectStore) {
115        let d = TempDir::new().unwrap();
116        let s = ObjectStore::init(d.path()).unwrap();
117        (d, s)
118    }
119    fn put_blob(s: &ObjectStore, data: &[u8]) -> Hash {
120        let bytes = serialize::serialize(&Object::Blob(Blob {
121            data: data.to_vec(),
122        }))
123        .unwrap();
124        s.write(&bytes).unwrap()
125    }
126    fn make_tree(s: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
127        let bytes = serialize::serialize(&Object::Tree(Tree { entries })).unwrap();
128        s.write(&bytes).unwrap()
129    }
130    fn entry(name: &[u8], mode: EntryMode, h: Hash) -> TreeEntry {
131        TreeEntry {
132            name: name.to_vec(),
133            mode,
134            object_hash: h,
135        }
136    }
137    fn make_commit(s: &ObjectStore, tree: Hash, parents: &[Hash], message: &str) -> Hash {
138        let c = Commit {
139            tree_hash: tree,
140            parents: parents.to_vec(),
141            author: Identity::ed25519([0; 32]),
142            signer: [0; 32],
143            message: message.as_bytes().to_vec(),
144            timestamp: message.len() as u64,
145            message_hash: [0; 32],
146            content_digest: [0; 32],
147            signature: [0; 64],
148        };
149        s.write(&serialize::serialize(&Object::Commit(c)).unwrap())
150            .unwrap()
151    }
152    fn tree_entries(s: &ObjectStore, h: Hash) -> Vec<TreeEntry> {
153        match s.read_object(&h).unwrap() {
154            Object::Tree(t) => t.entries,
155            other => panic!("expected tree, got {other}"),
156        }
157    }
158
159    #[test]
160    fn adds_a_file_onto_branch_missing_it() {
161        let (_d, s) = store();
162        let blob_a = put_blob(&s, b"aaa");
163        let base_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
164        let base_commit = make_commit(&s, base_tree, &[], "initial");
165        let blob_b = put_blob(&s, b"bbb");
166        let target_tree = make_tree(
167            &s,
168            vec![
169                entry(b"a.txt", EntryMode::Blob, blob_a),
170                entry(b"b.txt", EntryMode::Blob, blob_b),
171            ],
172        );
173        let target_commit = make_commit(&s, target_tree, &[base_commit], "add b.txt");
174
175        let r = cherry_pick(&s, target_commit, base_tree).unwrap();
176        assert!(!r.has_conflicts());
177        assert_eq!(r.original_message, b"add b.txt");
178        let merged = tree_entries(&s, r.tree_hash);
179        assert_eq!(merged.len(), 2);
180    }
181
182    #[test]
183    fn modify_modify_conflict() {
184        let (_d, s) = store();
185        let blob_orig = put_blob(&s, b"original");
186        let base_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_orig)]);
187        let base_commit = make_commit(&s, base_tree, &[], "initial");
188        let blob_theirs = put_blob(&s, b"theirs-change");
189        let target_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_theirs)]);
190        let target_commit = make_commit(&s, target_tree, &[base_commit], "change a.txt");
191        let blob_ours = put_blob(&s, b"ours-change");
192        let ours_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_ours)]);
193
194        let r = cherry_pick(&s, target_commit, ours_tree).unwrap();
195        assert!(r.has_conflicts());
196        assert_eq!(r.conflicts.len(), 1);
197        assert_eq!(r.conflicts[0].path, "a.txt");
198        assert_eq!(r.conflicts[0].kind, ConflictKind::ModifyModify);
199        assert_eq!(r.original_message, b"change a.txt");
200    }
201
202    #[test]
203    fn root_commit_no_parent() {
204        let (_d, s) = store();
205        let blob_a = put_blob(&s, b"aaa");
206        let root_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
207        let root_commit = make_commit(&s, root_tree, &[], "root commit");
208        let blob_b = put_blob(&s, b"bbb");
209        let ours_tree = make_tree(&s, vec![entry(b"b.txt", EntryMode::Blob, blob_b)]);
210        let r = cherry_pick(&s, root_commit, ours_tree).unwrap();
211        assert!(!r.has_conflicts());
212        assert_eq!(r.original_message, b"root commit");
213        assert_eq!(tree_entries(&s, r.tree_hash).len(), 2);
214    }
215
216    #[test]
217    fn delete_modify_conflict() {
218        let (_d, s) = store();
219        let blob_a = put_blob(&s, b"original");
220        let base_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
221        let base_commit = make_commit(&s, base_tree, &[], "initial");
222        let target_tree = make_tree(&s, vec![]);
223        let target_commit = make_commit(&s, target_tree, &[base_commit], "remove a.txt");
224        let blob_modified = put_blob(&s, b"modified content");
225        let ours_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_modified)]);
226        let r = cherry_pick(&s, target_commit, ours_tree).unwrap();
227        assert!(r.has_conflicts());
228        assert_eq!(r.conflicts[0].kind, ConflictKind::DeleteModify);
229        assert_eq!(r.conflicts[0].path, "a.txt");
230    }
231
232    #[test]
233    fn adds_multiple_files() {
234        let (_d, s) = store();
235        let blob_a = put_blob(&s, b"aaa");
236        let base_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
237        let base_commit = make_commit(&s, base_tree, &[], "initial");
238        let blob_b = put_blob(&s, b"bbb");
239        let blob_c = put_blob(&s, b"ccc");
240        let blob_d = put_blob(&s, b"ddd");
241        let target_tree = make_tree(
242            &s,
243            vec![
244                entry(b"a.txt", EntryMode::Blob, blob_a),
245                entry(b"b.txt", EntryMode::Blob, blob_b),
246                entry(b"c.txt", EntryMode::Blob, blob_c),
247                entry(b"d.txt", EntryMode::Blob, blob_d),
248            ],
249        );
250        let target_commit = make_commit(&s, target_tree, &[base_commit], "add b, c, d");
251        let r = cherry_pick(&s, target_commit, base_tree).unwrap();
252        assert!(!r.has_conflicts());
253        assert_eq!(tree_entries(&s, r.tree_hash).len(), 4);
254    }
255
256    #[test]
257    fn non_commit_input_returns_error() {
258        let (_d, s) = store();
259        let blob_hash = put_blob(&s, b"just a blob");
260        let empty_tree = make_tree(&s, vec![]);
261        let err = cherry_pick(&s, blob_hash, empty_tree).unwrap_err();
262        assert!(matches!(err, CherryPickError::NotACommit));
263    }
264
265    #[test]
266    fn root_commit_onto_empty_ours() {
267        let (_d, s) = store();
268        let blob_a = put_blob(&s, b"aaa");
269        let blob_b = put_blob(&s, b"bbb");
270        let root_tree = make_tree(
271            &s,
272            vec![
273                entry(b"a.txt", EntryMode::Blob, blob_a),
274                entry(b"b.txt", EntryMode::Blob, blob_b),
275            ],
276        );
277        let root_commit = make_commit(&s, root_tree, &[], "root");
278        let empty_tree = make_tree(&s, vec![]);
279        let r = cherry_pick(&s, root_commit, empty_tree).unwrap();
280        assert!(!r.has_conflicts());
281        assert_eq!(tree_entries(&s, r.tree_hash).len(), 2);
282    }
283}