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    /// The target is a merge commit but no mainline parent was selected
28    /// (git: "commit … is a merge but no -m option was given").
29    #[error("commit is a merge but no mainline (-m <parent-number>) was given")]
30    MergeNeedsMainline,
31    /// A mainline parent was given for a non-merge commit (git: "mainline
32    /// was specified but commit … is not a merge").
33    #[error("mainline (-m) was specified but the commit is not a merge")]
34    MainlineForNonMerge,
35    /// The mainline parent number is out of range for this merge commit.
36    #[error("invalid mainline parent {given}: commit has {parents} parents")]
37    BadMainline { given: usize, parents: usize },
38    #[error(transparent)]
39    Store(#[from] StoreError),
40}
41
42/// Result of [`cherry_pick`]. `tree_hash` is the merged tree (always
43/// written to the store, even on conflict — the merged tree contains
44/// "ours" at every conflicting path). `original_message` is the target
45/// commit's message verbatim, so the caller can use it as the basis
46/// for a new commit.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct CherryPickResult {
49    pub tree_hash: Hash,
50    pub conflicts: Vec<Conflict>,
51    pub original_message: Vec<u8>,
52}
53
54impl CherryPickResult {
55    #[must_use]
56    pub fn has_conflicts(&self) -> bool {
57        !self.conflicts.is_empty()
58    }
59}
60
61/// Cherry-pick `target_hash` onto `ours_tree`.
62///
63/// Algorithm:
64///
65/// 1. Load the target commit. (Error if not a commit.)
66/// 2. Load the target's first parent's tree as the merge `base`. If
67///    the target is a root commit (no parents), `base = None` (empty
68///    tree).
69/// 3. 3-way merge `(base, ours_tree, target.tree_hash)`.
70/// 4. Return the merged tree hash, any conflicts, and the target
71///    commit's `message` so the caller can craft a new commit.
72///
73/// # Errors
74///
75/// * [`CherryPickError::NotACommit`] when `target_hash` doesn't point
76///   at a commit object.
77/// * [`CherryPickError::ParentNotACommit`] when the parent hash points
78///   at something other than a commit.
79/// * [`CherryPickError::Store`] for any wrapped store/serialize error.
80pub fn cherry_pick(
81    store: &ObjectStore,
82    target_hash: Hash,
83    ours_tree: Hash,
84    mainline: Option<usize>,
85) -> Result<CherryPickResult, CherryPickError> {
86    let Object::Commit(target_commit) = store.read_object(&target_hash)? else {
87        return Err(CherryPickError::NotACommit);
88    };
89
90    // Pick the parent whose diff is replayed, with git's `-m`/`--mainline`
91    // semantics: a non-merge takes its sole parent (and rejects `-m`), while
92    // a merge REQUIRES `-m <parent-number>` to choose the mainline (git
93    // refuses to guess). The selected parent's tree is the base of the diff.
94    let n_parents = target_commit.parents.len();
95    let base_parent: Option<Hash> = match (n_parents, mainline) {
96        (0, None) => None,
97        (0 | 1, Some(_)) => return Err(CherryPickError::MainlineForNonMerge),
98        (1, None) => Some(target_commit.parents[0]),
99        (_, None) => return Err(CherryPickError::MergeNeedsMainline),
100        (_, Some(m)) if m >= 1 && m <= n_parents => Some(target_commit.parents[m - 1]),
101        (_, Some(m)) => {
102            return Err(CherryPickError::BadMainline {
103                given: m,
104                parents: n_parents,
105            });
106        }
107    };
108
109    let parent_tree: Option<Hash> = match base_parent {
110        None => None,
111        Some(parent_hash) => {
112            let Object::Commit(parent_commit) = store.read_object(&parent_hash)? else {
113                return Err(CherryPickError::ParentNotACommit);
114            };
115            Some(parent_commit.tree_hash)
116        }
117    };
118
119    let original_message = target_commit.message.clone();
120    let merge_result = merge::merge_trees(
121        store,
122        parent_tree,
123        Some(ours_tree),
124        Some(target_commit.tree_hash),
125    )?;
126
127    Ok(CherryPickResult {
128        tree_hash: merge_result.tree_hash,
129        conflicts: merge_result.conflicts,
130        original_message,
131    })
132}
133
134// =====================================================================
135// Tests
136// =====================================================================
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::object::{Blob, Commit, EntryMode, Identity, Object, Tree, TreeEntry};
142    use crate::ops::merge::ConflictKind;
143    use crate::serialize;
144    use tempfile::TempDir;
145
146    fn store() -> (TempDir, ObjectStore) {
147        let d = TempDir::new().unwrap();
148        let s = ObjectStore::init(&crate::layout::RepoLayout::single(d.path())).unwrap();
149        (d, s)
150    }
151    fn put_blob(s: &ObjectStore, data: &[u8]) -> Hash {
152        let bytes = serialize::serialize(&Object::Blob(Blob {
153            data: data.to_vec(),
154        }))
155        .unwrap();
156        s.write(&bytes).unwrap()
157    }
158    fn make_tree(s: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
159        let bytes = serialize::serialize(&Object::Tree(Tree { entries })).unwrap();
160        s.write(&bytes).unwrap()
161    }
162    fn entry(name: &[u8], mode: EntryMode, h: Hash) -> TreeEntry {
163        TreeEntry {
164            name: name.to_vec(),
165            mode,
166            object_hash: h,
167        }
168    }
169    fn make_commit(s: &ObjectStore, tree: Hash, parents: &[Hash], message: &str) -> Hash {
170        let c = Commit {
171            tree_hash: tree,
172            parents: parents.to_vec(),
173            author: Identity::ed25519([0; 32]),
174            signer: [0; 32],
175            message: message.as_bytes().to_vec(),
176            timestamp: message.len() as u64,
177            message_hash: [0; 32],
178            content_digest: [0; 32],
179            signature: [0; 64],
180        };
181        s.write(&serialize::serialize(&Object::Commit(c)).unwrap())
182            .unwrap()
183    }
184    fn tree_entries(s: &ObjectStore, h: Hash) -> Vec<TreeEntry> {
185        match s.read_object(&h).unwrap() {
186            Object::Tree(t) => t.entries,
187            other => panic!("expected tree, got {other}"),
188        }
189    }
190
191    #[test]
192    fn adds_a_file_onto_branch_missing_it() {
193        let (_d, s) = store();
194        let blob_a = put_blob(&s, b"aaa");
195        let base_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
196        let base_commit = make_commit(&s, base_tree, &[], "initial");
197        let blob_b = put_blob(&s, b"bbb");
198        let target_tree = make_tree(
199            &s,
200            vec![
201                entry(b"a.txt", EntryMode::Blob, blob_a),
202                entry(b"b.txt", EntryMode::Blob, blob_b),
203            ],
204        );
205        let target_commit = make_commit(&s, target_tree, &[base_commit], "add b.txt");
206
207        let r = cherry_pick(&s, target_commit, base_tree, None).unwrap();
208        assert!(!r.has_conflicts());
209        assert_eq!(r.original_message, b"add b.txt");
210        let merged = tree_entries(&s, r.tree_hash);
211        assert_eq!(merged.len(), 2);
212    }
213
214    #[test]
215    fn modify_modify_conflict() {
216        let (_d, s) = store();
217        let blob_orig = put_blob(&s, b"original");
218        let base_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_orig)]);
219        let base_commit = make_commit(&s, base_tree, &[], "initial");
220        let blob_theirs = put_blob(&s, b"theirs-change");
221        let target_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_theirs)]);
222        let target_commit = make_commit(&s, target_tree, &[base_commit], "change a.txt");
223        let blob_ours = put_blob(&s, b"ours-change");
224        let ours_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_ours)]);
225
226        let r = cherry_pick(&s, target_commit, ours_tree, None).unwrap();
227        assert!(r.has_conflicts());
228        assert_eq!(r.conflicts.len(), 1);
229        assert_eq!(r.conflicts[0].path, "a.txt");
230        assert_eq!(r.conflicts[0].kind, ConflictKind::ModifyModify);
231        assert_eq!(r.original_message, b"change a.txt");
232    }
233
234    #[test]
235    fn root_commit_no_parent() {
236        let (_d, s) = store();
237        let blob_a = put_blob(&s, b"aaa");
238        let root_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
239        let root_commit = make_commit(&s, root_tree, &[], "root commit");
240        let blob_b = put_blob(&s, b"bbb");
241        let ours_tree = make_tree(&s, vec![entry(b"b.txt", EntryMode::Blob, blob_b)]);
242        let r = cherry_pick(&s, root_commit, ours_tree, None).unwrap();
243        assert!(!r.has_conflicts());
244        assert_eq!(r.original_message, b"root commit");
245        assert_eq!(tree_entries(&s, r.tree_hash).len(), 2);
246    }
247
248    #[test]
249    fn delete_modify_conflict() {
250        let (_d, s) = store();
251        let blob_a = put_blob(&s, b"original");
252        let base_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
253        let base_commit = make_commit(&s, base_tree, &[], "initial");
254        let target_tree = make_tree(&s, vec![]);
255        let target_commit = make_commit(&s, target_tree, &[base_commit], "remove a.txt");
256        let blob_modified = put_blob(&s, b"modified content");
257        let ours_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_modified)]);
258        let r = cherry_pick(&s, target_commit, ours_tree, None).unwrap();
259        assert!(r.has_conflicts());
260        assert_eq!(r.conflicts[0].kind, ConflictKind::DeleteModify);
261        assert_eq!(r.conflicts[0].path, "a.txt");
262    }
263
264    #[test]
265    fn adds_multiple_files() {
266        let (_d, s) = store();
267        let blob_a = put_blob(&s, b"aaa");
268        let base_tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
269        let base_commit = make_commit(&s, base_tree, &[], "initial");
270        let blob_b = put_blob(&s, b"bbb");
271        let blob_c = put_blob(&s, b"ccc");
272        let blob_d = put_blob(&s, b"ddd");
273        let target_tree = make_tree(
274            &s,
275            vec![
276                entry(b"a.txt", EntryMode::Blob, blob_a),
277                entry(b"b.txt", EntryMode::Blob, blob_b),
278                entry(b"c.txt", EntryMode::Blob, blob_c),
279                entry(b"d.txt", EntryMode::Blob, blob_d),
280            ],
281        );
282        let target_commit = make_commit(&s, target_tree, &[base_commit], "add b, c, d");
283        let r = cherry_pick(&s, target_commit, base_tree, None).unwrap();
284        assert!(!r.has_conflicts());
285        assert_eq!(tree_entries(&s, r.tree_hash).len(), 4);
286    }
287
288    #[test]
289    fn non_commit_input_returns_error() {
290        let (_d, s) = store();
291        let blob_hash = put_blob(&s, b"just a blob");
292        let empty_tree = make_tree(&s, vec![]);
293        let err = cherry_pick(&s, blob_hash, empty_tree, None).unwrap_err();
294        assert!(matches!(err, CherryPickError::NotACommit));
295    }
296
297    #[test]
298    fn root_commit_onto_empty_ours() {
299        let (_d, s) = store();
300        let blob_a = put_blob(&s, b"aaa");
301        let blob_b = put_blob(&s, b"bbb");
302        let root_tree = make_tree(
303            &s,
304            vec![
305                entry(b"a.txt", EntryMode::Blob, blob_a),
306                entry(b"b.txt", EntryMode::Blob, blob_b),
307            ],
308        );
309        let root_commit = make_commit(&s, root_tree, &[], "root");
310        let empty_tree = make_tree(&s, vec![]);
311        let r = cherry_pick(&s, root_commit, empty_tree, None).unwrap();
312        assert!(!r.has_conflicts());
313        assert_eq!(tree_entries(&s, r.tree_hash).len(), 2);
314    }
315
316    /// Build a 2-parent merge commit. Parent 1 has `a.txt`, parent 2 has
317    /// `b.txt`; the merge tree has both, plus `m.txt` unique to the merge.
318    fn merge_fixture(s: &ObjectStore) -> (Hash, Hash, Hash) {
319        let a = put_blob(s, b"a");
320        let b = put_blob(s, b"b");
321        let p1_tree = make_tree(s, vec![entry(b"a.txt", EntryMode::Blob, a)]);
322        let p2_tree = make_tree(s, vec![entry(b"b.txt", EntryMode::Blob, b)]);
323        let p1 = make_commit(s, p1_tree, &[], "p1");
324        let p2 = make_commit(s, p2_tree, &[], "p2");
325        let m = put_blob(s, b"m");
326        let merge_tree = make_tree(
327            s,
328            vec![
329                entry(b"a.txt", EntryMode::Blob, a),
330                entry(b"b.txt", EntryMode::Blob, b),
331                entry(b"m.txt", EntryMode::Blob, m),
332            ],
333        );
334        let merge_commit = make_commit(s, merge_tree, &[p1, p2], "merge");
335        (merge_commit, p1, p2)
336    }
337
338    #[test]
339    fn merge_without_mainline_is_rejected() {
340        let (_d, s) = store();
341        let (merge_commit, _p1, _p2) = merge_fixture(&s);
342        let empty = make_tree(&s, vec![]);
343        let err = cherry_pick(&s, merge_commit, empty, None).unwrap_err();
344        assert!(matches!(err, CherryPickError::MergeNeedsMainline));
345    }
346
347    #[test]
348    fn mainline_on_non_merge_is_rejected() {
349        let (_d, s) = store();
350        let blob_a = put_blob(&s, b"aaa");
351        let tree = make_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
352        let root = make_commit(&s, tree, &[], "root");
353        let child = make_commit(&s, tree, &[root], "child");
354        let empty = make_tree(&s, vec![]);
355        let err = cherry_pick(&s, child, empty, Some(1)).unwrap_err();
356        assert!(matches!(err, CherryPickError::MainlineForNonMerge));
357    }
358
359    #[test]
360    fn out_of_range_mainline_is_rejected() {
361        let (_d, s) = store();
362        let (merge_commit, _p1, _p2) = merge_fixture(&s);
363        let empty = make_tree(&s, vec![]);
364        let err = cherry_pick(&s, merge_commit, empty, Some(3)).unwrap_err();
365        assert!(matches!(
366            err,
367            CherryPickError::BadMainline {
368                given: 3,
369                parents: 2
370            }
371        ));
372    }
373
374    #[test]
375    fn mainline_selects_the_chosen_parent_as_base() {
376        // Picking the merge onto an empty tree with `-m 1` replays the merge's
377        // diff against parent 1 (which has a.txt) → adds b.txt + m.txt; with
378        // `-m 2` (parent has b.txt) → adds a.txt + m.txt. Different bases ⇒
379        // different results, proving the mainline actually selects the parent.
380        let (_d, s) = store();
381        let (merge_commit, _p1, _p2) = merge_fixture(&s);
382        let empty = make_tree(&s, vec![]);
383        let r1 = cherry_pick(&s, merge_commit, empty, Some(1)).unwrap();
384        let r2 = cherry_pick(&s, merge_commit, empty, Some(2)).unwrap();
385        assert_ne!(
386            r1.tree_hash, r2.tree_hash,
387            "different mainline parents must yield different patches"
388        );
389    }
390}