Skip to main content

mkit_cli/commands/
cherry_pick.rs

1//! `mkit cherry-pick <commit> | --continue | --abort` — replay a single
2//! commit onto HEAD, with a resolvable-conflict workflow (#177).
3//!
4//! On a clean merge we create a new commit on the current branch using
5//! the original commit's message. On conflict we materialise the
6//! conflict material into the worktree + index, persist
7//! `CHERRY_PICK_HEAD`/`CHERRY_PICK_MSG`/`ORIG_HEAD` and the
8//! `mkit-conflicts` sidecar, and exit non-zero. The user resolves,
9//! `mkit add`s, then runs `mkit cherry-pick --continue`.
10//!
11//! `--continue` refuses unless `CHERRY_PICK_HEAD` exists and no
12//! marker-bearing file remains; it builds the final tree from the
13//! resolved index. `--abort` restores HEAD/ref/index/worktree to
14//! `ORIG_HEAD`.
15
16use std::io::Write;
17
18use clap::Parser;
19use mkit_core::hash::Hash;
20use mkit_core::object::{Commit, Object};
21use mkit_core::ops::cherry_pick::cherry_pick;
22use mkit_core::ops::conflict_state::{
23    self, CherryPickState, in_progress_op_name, is_cherry_pick_in_progress,
24};
25use mkit_core::refs::{self, Head};
26use mkit_core::serialize;
27use mkit_core::store::ObjectStore;
28use mkit_core::worktree;
29
30use crate::clap_shim;
31use crate::config;
32use crate::exit;
33use crate::format;
34
35#[derive(Debug, Parser)]
36#[command(name = "mkit cherry-pick", about = "Apply a single commit onto HEAD.")]
37struct CherryPickOpts {
38    /// Continue an in-progress cherry-pick after resolving conflicts.
39    #[arg(long = "continue", conflicts_with_all = ["abort", "commit"])]
40    cont: bool,
41    /// Abort the in-progress cherry-pick and restore the original HEAD.
42    #[arg(long, conflicts_with_all = ["cont", "commit"])]
43    abort: bool,
44    /// Commit to replay: a ref, full/short hash, or `HEAD~n` revspec.
45    commit: Option<String>,
46}
47
48#[must_use]
49pub fn run(args: &[String]) -> u8 {
50    let opts = match clap_shim::parse::<CherryPickOpts>("mkit cherry-pick", args) {
51        Ok(o) => o,
52        Err(code) => return code,
53    };
54    let cwd = match std::env::current_dir() {
55        Ok(p) => p,
56        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
57    };
58    let store = match ObjectStore::open(&cwd) {
59        Ok(s) => s,
60        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
61    };
62    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
63    let _lock = match super::acquire_worktree_lock(&cwd) {
64        Ok(l) => l,
65        Err(code) => return code,
66    };
67
68    if opts.abort {
69        abort(&cwd, &mkit_dir, &store)
70    } else if opts.cont {
71        cont(&cwd, &mkit_dir, &store)
72    } else if let Some(hex) = opts.commit.as_deref() {
73        start(&cwd, &mkit_dir, &store, hex)
74    } else {
75        super::usage_error("usage: mkit cherry-pick <commit> | --continue | --abort")
76    }
77}
78
79#[allow(clippy::too_many_lines)]
80fn start(cwd: &std::path::Path, mkit_dir: &std::path::Path, store: &ObjectStore, hex: &str) -> u8 {
81    if let Some(op) = in_progress_op_name(mkit_dir) {
82        return emit_err(
83            &format!("a {op} is already in progress (use --continue or --abort)"),
84            exit::GENERAL_ERROR,
85        );
86    }
87    let target: Hash = match super::revspec::resolve_revision(store, mkit_dir, hex) {
88        Ok(h) => h,
89        Err(e) => return emit_err(&format!("bad commit: {e}"), exit::DATAERR),
90    };
91
92    let ours = match refs::resolve_head(mkit_dir) {
93        Ok(Some(h)) => h,
94        Ok(None) => return emit_err("no commits on current branch", exit::GENERAL_ERROR),
95        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
96    };
97    let ours_tree = match store.read_object(&ours) {
98        Ok(Object::Commit(c)) => c.tree_hash,
99        Ok(_) => return emit_err("HEAD is not a commit", exit::DATAERR),
100        Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::GENERAL_ERROR),
101    };
102
103    let result = match cherry_pick(store, target, ours_tree) {
104        Ok(r) => r,
105        Err(e) => return emit_err(&format!("cherry-pick: {e}"), exit::GENERAL_ERROR),
106    };
107
108    if result.has_conflicts() {
109        if let Err(e) = super::ensure_restore_safe(cwd, store, result.tree_hash) {
110            return emit_err(&e, exit::GENERAL_ERROR);
111        }
112        let records = match super::conflict::materialize_conflicts(
113            cwd,
114            store,
115            result.tree_hash,
116            &result.conflicts,
117        ) {
118            Ok(r) => r,
119            Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
120        };
121        let state = CherryPickState {
122            cherry_pick_head: target,
123            orig_head: ours,
124            message: result.original_message.clone(),
125        };
126        if let Err(e) = conflict_state::write_cherry_pick_state(mkit_dir, &state, &records) {
127            return emit_err(&format!("write cherry-pick state: {e}"), exit::CANTCREAT);
128        }
129        let mut stderr = std::io::stderr().lock();
130        let _ = writeln!(
131            stderr,
132            "cherry-pick conflict; resolve the files above, `mkit add` them, then run \
133             `mkit cherry-pick --continue` (or `mkit cherry-pick --abort`)"
134        );
135        return exit::GENERAL_ERROR;
136    }
137
138    if let Err(e) = super::ensure_restore_safe(cwd, store, result.tree_hash) {
139        return emit_err(&e, exit::GENERAL_ERROR);
140    }
141
142    let commit_hash = match create_commit(
143        cwd,
144        store,
145        result.tree_hash,
146        ours,
147        &result.original_message,
148        target,
149    ) {
150        Ok(h) => h,
151        Err(code) => return code,
152    };
153    if let Err(e) = super::restore_worktree_and_index(cwd, store, result.tree_hash) {
154        return emit_err(&e, exit::GENERAL_ERROR);
155    }
156    if let Err(e) = advance_head(mkit_dir, &commit_hash) {
157        return emit_err(&e, exit::CANTCREAT);
158    }
159    let mut stderr = std::io::stderr().lock();
160    let _ = writeln!(
161        stderr,
162        "cherry-picked {} onto {} as {}",
163        format::short_hash(&target, 8),
164        format::short_hash(&ours, 8),
165        format::short_hash(&commit_hash, 8),
166    );
167    exit::OK
168}
169
170fn cont(cwd: &std::path::Path, mkit_dir: &std::path::Path, store: &ObjectStore) -> u8 {
171    if !is_cherry_pick_in_progress(mkit_dir) {
172        return emit_err("no cherry-pick in progress", exit::GENERAL_ERROR);
173    }
174    let state = match conflict_state::read_cherry_pick_state(mkit_dir) {
175        Ok(Some(s)) => s,
176        Ok(None) => return emit_err("no cherry-pick in progress", exit::GENERAL_ERROR),
177        Err(e) => return emit_err(&format!("read cherry-pick state: {e}"), exit::GENERAL_ERROR),
178    };
179    let records = match conflict_state::read_conflicts(mkit_dir) {
180        Ok(r) => r,
181        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
182    };
183    match super::conflict::first_unresolved_marker(cwd, &records) {
184        Ok(Some(path)) => {
185            return emit_err(
186                &format!(
187                    "unresolved conflict markers remain in '{path}'; resolve and `mkit add` it"
188                ),
189                exit::GENERAL_ERROR,
190            );
191        }
192        Ok(None) => {}
193        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
194    }
195    if let Err(e) = super::conflict::ensure_conflict_paths_staged(cwd, store, &records) {
196        return emit_err(&e, exit::GENERAL_ERROR);
197    }
198
199    // Single parent = current HEAD (== orig_head). Build tree from the
200    // resolved index, NOT the conflict-time tree.
201    let idx = match super::read_or_seed_index_from_head(cwd, store) {
202        Ok(i) => i,
203        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
204    };
205    let tree_hash = match worktree::build_tree_from_index(store, &idx) {
206        Ok(t) => t,
207        Err(e) => return emit_err(&format!("build tree from index: {e}"), exit::GENERAL_ERROR),
208    };
209
210    let parent = match refs::resolve_head(mkit_dir) {
211        Ok(Some(h)) => h,
212        Ok(None) => state.orig_head,
213        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
214    };
215    let commit_hash = match create_commit(
216        cwd,
217        store,
218        tree_hash,
219        parent,
220        &state.message,
221        state.cherry_pick_head,
222    ) {
223        Ok(h) => h,
224        Err(code) => return code,
225    };
226    if let Err(e) = super::restore_worktree_and_index(cwd, store, tree_hash) {
227        return emit_err(&e, exit::GENERAL_ERROR);
228    }
229    if let Err(e) = advance_head(mkit_dir, &commit_hash) {
230        return emit_err(&e, exit::CANTCREAT);
231    }
232    if let Err(e) = conflict_state::clear_cherry_pick_state(mkit_dir) {
233        return emit_err(
234            &format!("clear cherry-pick state: {e}"),
235            exit::GENERAL_ERROR,
236        );
237    }
238    let mut stderr = std::io::stderr().lock();
239    let _ = writeln!(
240        stderr,
241        "cherry-picked {} as {}",
242        format::short_hash(&state.cherry_pick_head, 8),
243        format::short_hash(&commit_hash, 8),
244    );
245    exit::OK
246}
247
248fn abort(cwd: &std::path::Path, mkit_dir: &std::path::Path, store: &ObjectStore) -> u8 {
249    if !is_cherry_pick_in_progress(mkit_dir) {
250        return emit_err("no cherry-pick in progress", exit::GENERAL_ERROR);
251    }
252    let state = match conflict_state::read_cherry_pick_state(mkit_dir) {
253        Ok(Some(s)) => s,
254        Ok(None) => return emit_err("no cherry-pick in progress", exit::GENERAL_ERROR),
255        Err(e) => return emit_err(&format!("read cherry-pick state: {e}"), exit::GENERAL_ERROR),
256    };
257    let records = match conflict_state::read_conflicts(mkit_dir) {
258        Ok(r) => r,
259        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
260    };
261    if let Err(code) = restore_to(cwd, mkit_dir, store, state.orig_head, &records) {
262        return code;
263    }
264    if let Err(e) = conflict_state::clear_cherry_pick_state(mkit_dir) {
265        return emit_err(
266            &format!("clear cherry-pick state: {e}"),
267            exit::GENERAL_ERROR,
268        );
269    }
270    let mut stderr = std::io::stderr().lock();
271    let _ = writeln!(stderr, "cherry-pick aborted; HEAD restored");
272    exit::OK
273}
274
275fn restore_to(
276    cwd: &std::path::Path,
277    mkit_dir: &std::path::Path,
278    store: &ObjectStore,
279    target: Hash,
280    records: &[mkit_core::ops::conflict_state::ConflictRecord],
281) -> Result<(), u8> {
282    let target_tree = load_tree_hash(store, target)?;
283    // Pre-flight: refuse before any mutation when the abort would clobber
284    // genuine user work on a non-conflict path (the reset below discards
285    // the user's in-progress conflict resolution, so it must not run if
286    // the abort is going to fail).
287    if let Err(e) = super::conflict::ensure_abort_safe(cwd, store, records, target_tree) {
288        return Err(emit_err(&e, exit::GENERAL_ERROR));
289    }
290    if let Err(e) = super::conflict::reset_conflict_paths(cwd, store, records, target_tree) {
291        return Err(emit_err(&e, exit::GENERAL_ERROR));
292    }
293    if let Err(e) = super::ensure_restore_safe(cwd, store, target_tree) {
294        return Err(emit_err(&e, exit::GENERAL_ERROR));
295    }
296    if let Err(e) = super::restore_worktree_and_index(cwd, store, target_tree) {
297        return Err(emit_err(&e, exit::GENERAL_ERROR));
298    }
299    let head = refs::read_head(mkit_dir).unwrap_or(Head::Branch("main".to_string()));
300    match head {
301        Head::Branch(name) => {
302            if let Err(e) = super::write_ref_recording_history(
303                mkit_dir,
304                &name,
305                refs::RefWriteCondition::Any,
306                &target,
307            ) {
308                return Err(emit_err(&format!("restore ref: {e}"), exit::CANTCREAT));
309            }
310        }
311        Head::Detached(_) => {
312            if let Err(e) = refs::write_head_detached(mkit_dir, &target) {
313                return Err(emit_err(&format!("restore HEAD: {e}"), exit::CANTCREAT));
314            }
315        }
316    }
317    Ok(())
318}
319
320fn create_commit(
321    cwd: &std::path::Path,
322    store: &ObjectStore,
323    tree_hash: Hash,
324    parent: Hash,
325    message: &[u8],
326    picked: Hash,
327) -> Result<Hash, u8> {
328    let cfg = config::read_or_default(cwd)
329        .map_err(|e| emit_err(&format!("config: {e}"), exit::CONFIG_ERROR))?;
330    let mut signer =
331        super::commit::load_commit_signer(cwd, &cfg).map_err(|(msg, code)| emit_err(&msg, code))?;
332    let signer_public = signer
333        .public_key()
334        .map_err(|(msg, code)| emit_err(&msg, code))?;
335    // A replay keeps the picked commit's authorship + timestamp (the
336    // fresh signature/signer mark the replay), matching git.
337    let (author, timestamp) = match store.read_object(&picked) {
338        Ok(Object::Commit(c)) => (c.author, c.timestamp),
339        Ok(_) => return Err(emit_err("picked object is not a commit", exit::DATAERR)),
340        Err(e) => {
341            return Err(emit_err(
342                &format!("read picked commit: {e}"),
343                exit::GENERAL_ERROR,
344            ));
345        }
346    };
347    let mut unsigned = Commit::new_unannotated(
348        tree_hash,
349        vec![parent],
350        author,
351        signer_public,
352        message.to_vec(),
353        timestamp,
354        [0u8; 64],
355    );
356    let sig = signer
357        .sign_commit(&unsigned)
358        .map_err(|(msg, code)| emit_err(&msg, code))?;
359    unsigned.signature = sig;
360    let bytes = serialize::serialize(&Object::Commit(unsigned))
361        .map_err(|e| emit_err(&format!("serialize: {e}"), exit::DATAERR))?;
362    store
363        .write(&bytes)
364        .map_err(|e| emit_err(&format!("store commit: {e}"), exit::CANTCREAT))
365}
366
367fn load_tree_hash(store: &ObjectStore, commit_hash: Hash) -> Result<Hash, u8> {
368    match store.read_object(&commit_hash) {
369        Ok(Object::Commit(c)) => Ok(c.tree_hash),
370        Ok(_) => Err(emit_err("object is not a commit", exit::DATAERR)),
371        Err(e) => Err(emit_err(&format!("read commit: {e}"), exit::GENERAL_ERROR)),
372    }
373}
374
375fn advance_head(mkit_dir: &std::path::Path, new_head: &Hash) -> Result<(), String> {
376    let head = refs::read_head(mkit_dir).unwrap_or(Head::Branch("main".to_string()));
377    match head {
378        Head::Branch(name) => super::write_ref_recording_history(
379            mkit_dir,
380            &name,
381            refs::RefWriteCondition::Any,
382            new_head,
383        )
384        .map_err(|e| format!("write ref: {e}")),
385        Head::Detached(_) => {
386            refs::write_head_detached(mkit_dir, new_head).map_err(|e| format!("update HEAD: {e}"))
387        }
388    }
389}
390
391fn emit_err(msg: &str, code: u8) -> u8 {
392    let mut stderr = std::io::stderr().lock();
393    let _ = writeln!(stderr, "error: {msg}");
394    code
395}