1use crate::hash::Hash;
13use crate::object::Object;
14use crate::store::{ObjectStore, StoreError};
15
16use super::merge::{self, Conflict};
17
18#[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("commit is a merge but no mainline (-m <parent-number>) was given")]
30 MergeNeedsMainline,
31 #[error("mainline (-m) was specified but the commit is not a merge")]
34 MainlineForNonMerge,
35 #[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#[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
61pub 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 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#[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 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 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}