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, ValueEnum};
19use mkit_core::hash::Hash;
20use mkit_core::layout::RepoLayout;
21use mkit_core::object::{Commit, Object};
22use mkit_core::ops::cherry_pick::{CherryPickError, cherry_pick};
23use mkit_core::ops::conflict_state::{
24    self, CherryPickState, in_progress_op_name, is_cherry_pick_in_progress,
25};
26use mkit_core::refs::{self, Head};
27use mkit_core::serialize;
28use mkit_core::store::ObjectStore;
29use mkit_core::worktree;
30
31use super::{advance_head, error as emit_err, load_tree_hash};
32use crate::clap_shim;
33use crate::config;
34use crate::exit;
35use crate::format::{self, JsonObject, json_string_array};
36
37#[derive(Debug, Clone, Copy, ValueEnum)]
38enum CherryPickFormat {
39    Default,
40    Json,
41}
42
43#[derive(Debug, Parser)]
44#[command(name = "mkit cherry-pick", about = "Apply a single commit onto HEAD.")]
45struct CherryPickOpts {
46    /// Continue an in-progress cherry-pick after resolving conflicts.
47    #[arg(long = "continue", conflicts_with_all = ["abort", "commit"])]
48    cont: bool,
49    /// Abort the in-progress cherry-pick and restore the original HEAD.
50    #[arg(long, conflicts_with_all = ["cont", "commit"])]
51    abort: bool,
52    /// Apply the picked change to the index + worktree without creating a
53    /// commit (like `git cherry-pick -n`). Run `mkit commit` when ready;
54    /// the result has the current branch as its single parent.
55    #[arg(short = 'n', long = "no-commit", conflicts_with_all = ["cont", "abort"])]
56    no_commit: bool,
57    /// Select the mainline parent (1-based) when replaying a merge commit,
58    /// like `git cherry-pick -m`. Required for a merge (mkit refuses to
59    /// guess which side is the mainline) and rejected for a non-merge.
60    #[arg(short = 'm', long = "mainline", value_name = "PARENT-NUMBER", conflicts_with_all = ["cont", "abort"])]
61    mainline: Option<usize>,
62    /// Emit a machine-readable JSON result object to stdout describing
63    /// the outcome: a new commit, `--no-commit` staging, a conflict
64    /// pause (`"conflicts":[<path>,...]`), or an error.
65    #[arg(long, value_enum, default_value = "default")]
66    format: CherryPickFormat,
67    /// Commit to replay: a ref, full/short hash, or `HEAD~n` revspec.
68    commit: Option<String>,
69}
70
71/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
72/// line on stdout.
73fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
74    if json {
75        let mut obj = JsonObject::new();
76        obj.field_bool("ok", false).field_str("error", msg);
77        let mut stdout = std::io::stdout().lock();
78        let _ = writeln!(stdout, "{}", obj.finish());
79    }
80    emit_err(msg, code)
81}
82
83#[must_use]
84pub fn run(args: &[String]) -> u8 {
85    let opts = match clap_shim::parse::<CherryPickOpts>("mkit cherry-pick", args) {
86        Ok(o) => o,
87        Err(code) => return code,
88    };
89    let json = matches!(opts.format, CherryPickFormat::Json);
90    let cwd = match std::env::current_dir() {
91        Ok(p) => p,
92        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
93    };
94    let layout = match super::resolve_layout(&cwd) {
95        Ok(layout) => layout,
96        Err(code) => return code,
97    };
98    let store = match ObjectStore::open(&layout) {
99        Ok(s) => s,
100        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
101    };
102    let _lock = match super::acquire_worktree_lock(&layout) {
103        Ok(l) => l,
104        Err(code) => return code,
105    };
106
107    if opts.abort {
108        abort(&layout, &store, json)
109    } else if opts.cont {
110        cont(&layout, &store, json)
111    } else if let Some(hex) = opts.commit.as_deref() {
112        start(&layout, &store, hex, opts.no_commit, opts.mainline, json)
113    } else {
114        super::usage_error("usage: mkit cherry-pick <commit> | --continue | --abort")
115    }
116}
117
118#[allow(clippy::too_many_lines)]
119fn start(
120    layout: &RepoLayout,
121    store: &ObjectStore,
122    hex: &str,
123    no_commit: bool,
124    mainline: Option<usize>,
125    json: bool,
126) -> u8 {
127    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
128    if let Some(op) = in_progress_op_name(layout) {
129        return emit_err(
130            &format!("a {op} is already in progress (use --continue or --abort)"),
131            exit::GENERAL_ERROR,
132        );
133    }
134    let target: Hash = match super::revspec::resolve_revision(store, layout, hex) {
135        // Peel annotated/signed tags to their target commit so
136        // `mkit cherry-pick <annotated-tag>` works like git (a tag is a
137        // ref, which the doc comment advertises as acceptable). Mirrors
138        // `merge`'s behavior.
139        Ok(h) => super::log::peel_tags(store, h),
140        Err(e) => return emit_err(&format!("bad commit: {e}"), exit::DATAERR),
141    };
142
143    let ours = match refs::resolve_head(layout) {
144        Ok(Some(h)) => h,
145        Ok(None) => return emit_err("no commits on current branch", exit::GENERAL_ERROR),
146        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
147    };
148    let ours_tree = match store.read_object(&ours) {
149        Ok(Object::Commit(c)) => c.tree_hash,
150        Ok(_) => return emit_err("HEAD is not a commit", exit::DATAERR),
151        Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::GENERAL_ERROR),
152    };
153
154    let result = match cherry_pick(store, target, ours_tree, mainline) {
155        Ok(r) => r,
156        // Mainline-selection misuse is a usage error (bad invocation),
157        // distinct from a runtime store/merge failure.
158        Err(
159            e @ (CherryPickError::MergeNeedsMainline
160            | CherryPickError::MainlineForNonMerge
161            | CherryPickError::BadMainline { .. }),
162        ) => return emit_err(&format!("cherry-pick: {e}"), exit::USAGE),
163        Err(e) => return emit_err(&format!("cherry-pick: {e}"), exit::GENERAL_ERROR),
164    };
165
166    if result.has_conflicts() {
167        // `-n` must never leave a committable conflict. mkit cannot represent
168        // a "staged but unresolved" conflict the way git's index can, and
169        // recording markers with no sequencer state would let a later
170        // `mkit commit` (which only guards merges) commit unresolved `<<<<<<<`
171        // markers. So we refuse the conflicting `-n` pick BEFORE touching the
172        // worktree — nothing is written. Re-run without `-n` (resumable via
173        // `--continue`/`--abort`) or resolve manually.
174        if no_commit {
175            return emit_err(
176                &format!(
177                    "cherry-pick -n of {} conflicts; mkit cannot stage an unresolved \
178                     conflict without committing — re-run without -n, then resolve and \
179                     `mkit cherry-pick --continue`",
180                    format::short_hash(&target, 8)
181                ),
182                exit::GENERAL_ERROR,
183            );
184        }
185        if let Err(e) = super::ensure_restore_safe(layout, store, result.tree_hash) {
186            return emit_err(&e, exit::GENERAL_ERROR);
187        }
188        let records = match super::conflict::materialize_conflicts(
189            layout,
190            store,
191            result.tree_hash,
192            &result.conflicts,
193        ) {
194            Ok(r) => r,
195            Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
196        };
197        let state = CherryPickState {
198            cherry_pick_head: target,
199            orig_head: ours,
200            message: result.original_message.clone(),
201        };
202        if let Err(e) = conflict_state::write_cherry_pick_state(layout, &state, &records) {
203            return emit_err(&format!("write cherry-pick state: {e}"), exit::CANTCREAT);
204        }
205        // Record the result tree so `--abort` treats the operation's clean
206        // hunks (not just conflict paths) as discardable.
207        if let Err(e) =
208            conflict_state::write_result_tree(layout.worktree_state_dir(), &result.tree_hash)
209        {
210            return emit_err(&format!("write cherry-pick state: {e}"), exit::CANTCREAT);
211        }
212        let mut stderr = std::io::stderr().lock();
213        // git-shaped per-path conflict lines (additive), then mkit's
214        // resumable-flow hint.
215        for rec in &records {
216            let _ = writeln!(stderr, "CONFLICT (content): Merge conflict in {}", rec.path);
217        }
218        let _ = writeln!(
219            stderr,
220            "hint: resolve the files above, `mkit add` them, then run \
221             `mkit cherry-pick --continue` (or `mkit cherry-pick --abort`)"
222        );
223        drop(stderr);
224        if json {
225            let paths: Vec<&str> = records.iter().map(|r| r.path.as_str()).collect();
226            let mut obj = JsonObject::new();
227            obj.field_bool("ok", false)
228                .field_str("kind", "conflict")
229                .field_raw("conflicts", &json_string_array(&paths))
230                .field_str(
231                    "error",
232                    "cherry-pick conflict; resolve and continue or abort",
233                );
234            let mut stdout = std::io::stdout().lock();
235            let _ = writeln!(stdout, "{}", obj.finish());
236        }
237        return exit::GENERAL_ERROR;
238    }
239
240    if let Err(e) = super::ensure_restore_safe(layout, store, result.tree_hash) {
241        return emit_err(&e, exit::GENERAL_ERROR);
242    }
243
244    // `--no-commit`: stage the picked tree into the index + worktree but do
245    // not commit or move HEAD. The next `mkit commit` records it as an
246    // ordinary single-parent commit on the current branch.
247    if no_commit {
248        if let Err(e) = super::restore_worktree_and_index(layout, store, result.tree_hash) {
249            return emit_err(&e, exit::GENERAL_ERROR);
250        }
251        // Restoring from a tree drops staged DELETIONS; re-stage them as
252        // tombstones so a deletion-bearing pick (or an all-deletions pick)
253        // stays staged and `mkit commit` records it.
254        if let Err(e) =
255            super::stage_removed_tombstones(layout, store, Some(ours_tree), result.tree_hash)
256        {
257            return emit_err(&e, exit::GENERAL_ERROR);
258        }
259        let mut stderr = std::io::stderr().lock();
260        let _ = writeln!(
261            stderr,
262            "staged cherry-pick of {} (no commit; run `mkit commit` when ready)",
263            format::short_hash(&target, 8),
264        );
265        drop(stderr);
266        if json {
267            let mut obj = JsonObject::new();
268            obj.field_bool("ok", true)
269                .field_str("kind", "no-commit")
270                .field_hash("picked", &target)
271                .field_hash("tree", &result.tree_hash);
272            let mut stdout = std::io::stdout().lock();
273            let _ = writeln!(stdout, "{}", obj.finish());
274        }
275        return exit::OK;
276    }
277
278    let commit_hash = match create_commit(
279        layout,
280        store,
281        result.tree_hash,
282        ours,
283        &result.original_message,
284        target,
285    ) {
286        Ok(h) => h,
287        Err(code) => return code,
288    };
289    if let Err(e) = super::restore_worktree_and_index(layout, store, result.tree_hash) {
290        return emit_err(&e, exit::GENERAL_ERROR);
291    }
292    if let Err(e) = advance_head(layout, &commit_hash) {
293        return emit_err(&e, exit::CANTCREAT);
294    }
295    // git-shaped summary: `[<branch> <hash>] <subject>` + diffstat.
296    let subject = String::from_utf8_lossy(&result.original_message)
297        .lines()
298        .next()
299        .unwrap_or("")
300        .to_owned();
301    let branch_name = match refs::read_head(layout) {
302        Ok(Head::Branch(b)) => Some(b),
303        _ => None,
304    };
305    let head_ref = match &branch_name {
306        Some(b) => super::summary::HeadRef::Branch(b),
307        None => super::summary::HeadRef::Detached,
308    };
309    let mut stderr = std::io::stderr().lock();
310    super::summary::print_commit_summary(
311        &mut stderr,
312        store,
313        &head_ref,
314        &commit_hash,
315        &subject,
316        false,
317        Some(ours_tree),
318        Some(result.tree_hash),
319    );
320    drop(stderr);
321    if json {
322        let mut obj = JsonObject::new();
323        obj.field_bool("ok", true)
324            .field_str("kind", "commit")
325            .field_hash("hash", &commit_hash)
326            .field_hash("picked", &target)
327            .field_hash("tree", &result.tree_hash);
328        let mut stdout = std::io::stdout().lock();
329        let _ = writeln!(stdout, "{}", obj.finish());
330    }
331    exit::OK
332}
333
334fn cont(layout: &RepoLayout, store: &ObjectStore, json: bool) -> u8 {
335    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
336    if !is_cherry_pick_in_progress(layout) {
337        return emit_err("no cherry-pick in progress", exit::GENERAL_ERROR);
338    }
339    let state = match conflict_state::read_cherry_pick_state(layout) {
340        Ok(Some(s)) => s,
341        Ok(None) => return emit_err("no cherry-pick in progress", exit::GENERAL_ERROR),
342        Err(e) => return emit_err(&format!("read cherry-pick state: {e}"), exit::GENERAL_ERROR),
343    };
344    let records = match conflict_state::read_conflicts(layout.worktree_state_dir()) {
345        Ok(r) => r,
346        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
347    };
348    match super::conflict::first_unresolved_marker(layout.worktree_root(), &records) {
349        Ok(Some(path)) => {
350            return emit_err(
351                &format!(
352                    "unresolved conflict markers remain in '{path}'; resolve and `mkit add` it"
353                ),
354                exit::GENERAL_ERROR,
355            );
356        }
357        Ok(None) => {}
358        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
359    }
360    if let Err(e) = super::conflict::ensure_conflict_paths_staged(layout, store, &records) {
361        return emit_err(&e, exit::GENERAL_ERROR);
362    }
363
364    // Single parent = current HEAD (== orig_head). Build tree from the
365    // resolved index, NOT the conflict-time tree.
366    let idx = match super::read_or_seed_index_from_head(layout, store) {
367        Ok(i) => i,
368        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
369    };
370    let tree_hash = match worktree::build_tree_from_index(store, &idx) {
371        Ok(t) => t,
372        Err(e) => return emit_err(&format!("build tree from index: {e}"), exit::GENERAL_ERROR),
373    };
374
375    let parent = match refs::resolve_head(layout) {
376        Ok(Some(h)) => h,
377        Ok(None) => state.orig_head,
378        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
379    };
380    let commit_hash = match create_commit(
381        layout,
382        store,
383        tree_hash,
384        parent,
385        &state.message,
386        state.cherry_pick_head,
387    ) {
388        Ok(h) => h,
389        Err(code) => return code,
390    };
391    // Sync the index to the committed tree WITHOUT rewriting the worktree:
392    // the tree was built from the index, so the worktree already holds the
393    // resolved content; restoring it would clobber any unstaged edits the
394    // user made (e.g. on a cleanly-merged path) before `--continue`.
395    if let Err(e) = super::sync_index_to_tree(layout, store, tree_hash) {
396        return emit_err(&e, exit::GENERAL_ERROR);
397    }
398    if let Err(e) = advance_head(layout, &commit_hash) {
399        return emit_err(&e, exit::CANTCREAT);
400    }
401    if let Err(e) = conflict_state::clear_cherry_pick_state(layout) {
402        return emit_err(
403            &format!("clear cherry-pick state: {e}"),
404            exit::GENERAL_ERROR,
405        );
406    }
407    let mut stderr = std::io::stderr().lock();
408    let _ = writeln!(
409        stderr,
410        "cherry-picked {} as {}",
411        format::short_hash(&state.cherry_pick_head, 8),
412        format::short_hash(&commit_hash, 8),
413    );
414    drop(stderr);
415    if json {
416        let mut obj = JsonObject::new();
417        obj.field_bool("ok", true)
418            .field_str("kind", "commit")
419            .field_hash("hash", &commit_hash)
420            .field_hash("picked", &state.cherry_pick_head)
421            .field_hash("tree", &tree_hash);
422        let mut stdout = std::io::stdout().lock();
423        let _ = writeln!(stdout, "{}", obj.finish());
424    }
425    exit::OK
426}
427
428fn abort(layout: &RepoLayout, store: &ObjectStore, json: bool) -> u8 {
429    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
430    if !is_cherry_pick_in_progress(layout) {
431        return emit_err("no cherry-pick in progress", exit::GENERAL_ERROR);
432    }
433    let state = match conflict_state::read_cherry_pick_state(layout) {
434        Ok(Some(s)) => s,
435        Ok(None) => return emit_err("no cherry-pick in progress", exit::GENERAL_ERROR),
436        Err(e) => return emit_err(&format!("read cherry-pick state: {e}"), exit::GENERAL_ERROR),
437    };
438    let records = match conflict_state::read_conflicts(layout.worktree_state_dir()) {
439        Ok(r) => r,
440        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
441    };
442    if let Err(code) = restore_to(layout, store, state.orig_head, &records) {
443        return code;
444    }
445    if let Err(e) = conflict_state::clear_cherry_pick_state(layout) {
446        return emit_err(
447            &format!("clear cherry-pick state: {e}"),
448            exit::GENERAL_ERROR,
449        );
450    }
451    let mut stderr = std::io::stderr().lock();
452    let _ = writeln!(stderr, "cherry-pick aborted; HEAD restored");
453    drop(stderr);
454    if json {
455        let mut obj = JsonObject::new();
456        obj.field_bool("ok", true)
457            .field_str("kind", "aborted")
458            .field_hash("hash", &state.orig_head);
459        let mut stdout = std::io::stdout().lock();
460        let _ = writeln!(stdout, "{}", obj.finish());
461    }
462    exit::OK
463}
464
465fn restore_to(
466    layout: &RepoLayout,
467    store: &ObjectStore,
468    target: Hash,
469    records: &[mkit_core::ops::conflict_state::ConflictRecord],
470) -> Result<(), u8> {
471    let target_tree = load_tree_hash(store, target)?;
472    // The operation's result tree lets the guards treat its clean hunks (not
473    // just conflict paths) as discardable.
474    let op_result = conflict_state::read_result_tree(layout.worktree_state_dir())
475        .ok()
476        .flatten();
477    // Pre-flight: refuse before any mutation when the abort would clobber
478    // genuine user work on a non-discardable path (the reset below discards
479    // the user's in-progress conflict resolution, so it must not run if
480    // the abort is going to fail).
481    if let Err(e) =
482        super::conflict::ensure_abort_safe(layout, store, records, target_tree, op_result)
483    {
484        return Err(emit_err(&e, exit::GENERAL_ERROR));
485    }
486    if let Err(e) =
487        super::conflict::reset_conflict_paths(layout, store, records, target_tree, op_result)
488    {
489        return Err(emit_err(&e, exit::GENERAL_ERROR));
490    }
491    if let Err(e) = super::ensure_restore_safe(layout, store, target_tree) {
492        return Err(emit_err(&e, exit::GENERAL_ERROR));
493    }
494    if let Err(e) = super::restore_worktree_and_index(layout, store, target_tree) {
495        return Err(emit_err(&e, exit::GENERAL_ERROR));
496    }
497    super::restore_head_ref(layout, &target)
498}
499
500fn create_commit(
501    layout: &RepoLayout,
502    store: &ObjectStore,
503    tree_hash: Hash,
504    parent: Hash,
505    message: &[u8],
506    picked: Hash,
507) -> Result<Hash, u8> {
508    let cfg = config::read_or_default(layout)
509        .map_err(|e| emit_err(&format!("config: {e}"), exit::CONFIG_ERROR))?;
510    let mut signer = super::commit::load_commit_signer(layout, &cfg)
511        .map_err(|(msg, code)| emit_err(&msg, code))?;
512    let signer_public = signer
513        .public_key()
514        .map_err(|(msg, code)| emit_err(&msg, code))?;
515    // A replay keeps the picked commit's authorship + timestamp (the
516    // fresh signature/signer mark the replay), matching git.
517    let (author, timestamp) = match store.read_object(&picked) {
518        Ok(Object::Commit(c)) => (c.author, c.timestamp),
519        Ok(_) => return Err(emit_err("picked object is not a commit", exit::DATAERR)),
520        Err(e) => {
521            return Err(emit_err(
522                &format!("read picked commit: {e}"),
523                exit::GENERAL_ERROR,
524            ));
525        }
526    };
527    let mut unsigned = Commit::new_unannotated(
528        tree_hash,
529        vec![parent],
530        author,
531        signer_public,
532        message.to_vec(),
533        timestamp,
534        [0u8; 64],
535    );
536    let sig = signer
537        .sign_commit(&unsigned)
538        .map_err(|(msg, code)| emit_err(&msg, code))?;
539    unsigned.signature = sig;
540    let bytes = serialize::serialize(&Object::Commit(unsigned))
541        .map_err(|e| emit_err(&format!("serialize: {e}"), exit::DATAERR))?;
542    store
543        .write(&bytes)
544        .map_err(|e| emit_err(&format!("store commit: {e}"), exit::CANTCREAT))
545}