Skip to main content

mkit_cli/commands/
rebase.rs

1//! `mkit rebase [-i] <revspec> | --continue | --abort | --skip` — replay
2//! commits onto a different base. The target is resolved through the
3//! shared revspec resolver, so a branch, tag, `HEAD~n`, or full/short
4//! hash all work.
5//!
6//! The rebase state machine lives in `mkit_core::ops::rebase`. This
7//! shim loads / writes that state and drives the replay loop via
8//! [`mkit_core::ops::cherry_pick()`].
9//!
10//! With `-i`/`--interactive`, the todo list is opened in `$EDITOR`
11//! before any mutation: lines can be reordered, `drop`ped (deleted),
12//! `reword`ed, or folded into the previous commit with `squash` (combine
13//! messages) / `fixup` (keep the previous message). Each commit's action
14//! is persisted alongside `todo` in the rebase state, so a reword/squash
15//! that pauses on conflict still reopens the editor on `--continue`. A
16//! squash/fixup may not be the first line. `edit` (stop to amend) is not
17//! yet supported and is rejected at parse time before HEAD is touched.
18//!
19//! On conflict the loop **pauses**: it materialises conflict material
20//! into the worktree + index (via the shared `conflict` helper) and
21//! writes a `mkit-conflicts` sidecar inside `.mkit/rebase-apply/`.
22//!
23//! `--continue` does NOT re-run cherry-pick on the paused commit (the
24//! #177 bug). Instead it builds the rewritten commit's tree from the
25//! resolved index/worktree, creates the commit, moves `todo[0]` to
26//! `done`, and keeps replaying the remaining commits.
27//!
28//! `--skip` drops the current `todo[0]` with no replacement commit and
29//! continues. `--abort` restores `HEAD` to `orig_head` and removes all
30//! rebase state (including the sidecar).
31
32use std::io::Write;
33
34use mkit_core::hash::Hash;
35use mkit_core::layout::RepoLayout;
36use mkit_core::object::{Commit, Identity, Object};
37use mkit_core::ops::cherry_pick::cherry_pick;
38use mkit_core::ops::conflict_state::{self, in_progress_op_name};
39use mkit_core::ops::rebase::{
40    RebaseAction, RebaseState, cleanup_rebase, collect_commits_to_replay, is_rebase_in_progress,
41    read_state, rebase_dir_path, write_state,
42};
43use mkit_core::refs::{self, Head};
44use mkit_core::serialize;
45use mkit_core::store::ObjectStore;
46use mkit_core::worktree;
47
48use clap::{Parser, ValueEnum};
49
50use crate::clap_shim;
51use crate::config;
52use crate::editor;
53use crate::exit;
54use crate::format::{self, JsonObject, json_string_array};
55
56#[derive(Debug, Clone, Copy, ValueEnum)]
57enum RebaseFormat {
58    Default,
59    Json,
60}
61
62#[derive(Debug, Parser)]
63#[command(name = "mkit rebase", about = "Replay commits onto a different base.")]
64// CLI flag struct: each bool is an independent clap switch.
65#[allow(clippy::struct_excessive_bools)]
66struct RebaseOpts {
67    /// Continue an in-progress rebase after resolving conflicts.
68    #[arg(long = "continue", conflicts_with_all = ["abort", "skip", "branch"])]
69    cont: bool,
70    /// Abort the in-progress rebase and restore the original HEAD.
71    #[arg(long, conflicts_with_all = ["cont", "skip", "branch"])]
72    abort: bool,
73    /// Skip the current commit (drop it) and continue the rebase.
74    #[arg(long, conflicts_with_all = ["cont", "abort", "branch"])]
75    skip: bool,
76    /// Edit the todo list in `$EDITOR` before replaying: reorder lines,
77    /// `drop` (or delete) lines, `reword`, or fold with `squash`/`fixup`.
78    /// (`edit` is not yet supported.)
79    #[arg(short = 'i', long, conflicts_with_all = ["cont", "abort", "skip"])]
80    interactive: bool,
81    /// Emit a machine-readable JSON result object to stdout describing
82    /// the outcome: a finished rebase, a conflict pause
83    /// (`"conflicts":[<path>,...]`), or an error. Best-effort on the
84    /// interactive (`-i`) editing path, which is inherently
85    /// human-in-the-loop.
86    #[arg(long, value_enum, default_value = "default")]
87    format: RebaseFormat,
88    /// Branch, tag, or revision (e.g. `HEAD~2`, a full/short hash) to
89    /// replay commits onto. Resolved through the shared revspec resolver.
90    branch: Option<String>,
91}
92
93/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
94/// line on stdout.
95fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
96    if json {
97        let mut obj = JsonObject::new();
98        obj.field_bool("ok", false).field_str("error", msg);
99        let mut stdout = std::io::stdout().lock();
100        let _ = writeln!(stdout, "{}", obj.finish());
101    }
102    emit_err(msg, code)
103}
104
105#[must_use]
106pub fn run(args: &[String]) -> u8 {
107    let opts = match clap_shim::parse::<RebaseOpts>("mkit rebase", args) {
108        Ok(o) => o,
109        Err(code) => return code,
110    };
111    let json = matches!(opts.format, RebaseFormat::Json);
112    let cwd = match std::env::current_dir() {
113        Ok(p) => p,
114        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
115    };
116    let layout = match super::resolve_layout(&cwd) {
117        Ok(layout) => layout,
118        Err(code) => return code,
119    };
120    let store = match ObjectStore::open(&layout) {
121        Ok(s) => s,
122        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
123    };
124    let _lock = match super::acquire_worktree_lock(&layout) {
125        Ok(l) => l,
126        Err(code) => return code,
127    };
128
129    if opts.abort {
130        abort(&layout, &store, json)
131    } else if opts.cont {
132        resume(&layout, &store, false, json)
133    } else if opts.skip {
134        resume(&layout, &store, true, json)
135    } else if let Some(branch) = opts.branch.as_deref() {
136        start(&layout, &store, branch, opts.interactive, json)
137    } else {
138        super::usage_error("usage: mkit rebase [-i] <revspec> | --continue | --abort | --skip")
139    }
140}
141
142fn start(
143    layout: &RepoLayout,
144    store: &ObjectStore,
145    branch: &str,
146    interactive: bool,
147    json: bool,
148) -> u8 {
149    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
150    if let Some(op) = in_progress_op_name(layout) {
151        return emit_err(
152            &format!("a {op} is already in progress (use --continue or --abort)"),
153            exit::GENERAL_ERROR,
154        );
155    }
156    // Resolve the rebase target through the shared revspec resolver
157    // (#227) so `rebase HEAD~2`, short/full hashes, tags, and branch
158    // names all work — the same grammar `reset`/`restore`/`cherry-pick`
159    // accept. The current branch name recorded in the rebase state
160    // (`head_name`) comes from HEAD below, not from this argument.
161    let onto = match super::revspec::resolve_revision(store, layout, branch) {
162        Ok(h) => h,
163        Err(e) => {
164            return emit_err(
165                &format!("no such commit: {branch} ({e})"),
166                exit::GENERAL_ERROR,
167            );
168        }
169    };
170    let orig_head = match refs::resolve_head(layout) {
171        Ok(Some(h)) => h,
172        Ok(None) => return emit_err("no commits on current branch", exit::GENERAL_ERROR),
173        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
174    };
175    let head_name = match refs::read_head(layout) {
176        Ok(Head::Branch(name)) => name,
177        Ok(Head::Detached(_)) => {
178            return emit_err("cannot rebase with detached HEAD", exit::GENERAL_ERROR);
179        }
180        Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::GENERAL_ERROR),
181    };
182    let candidates = match collect_commits_to_replay(store, orig_head, onto) {
183        Ok(v) => v,
184        Err(e) => return emit_err(&format!("collect commits: {e}"), exit::GENERAL_ERROR),
185    };
186
187    // Already at the target → nothing to do (git's `Current branch … is up
188    // to date.`), for both interactive and non-interactive. When HEAD is
189    // merely *behind* `onto` (an ancestor of it) we fall through and let the
190    // finalize flow fast-forward the branch.
191    if orig_head == onto {
192        let mut stderr = std::io::stderr().lock();
193        let _ = writeln!(stderr, "Current branch {head_name} is up to date.");
194        drop(stderr);
195        if json {
196            let mut obj = JsonObject::new();
197            obj.field_bool("ok", true)
198                .field_str("kind", "up-to-date")
199                .field_hash("hash", &orig_head);
200            let mut stdout = std::io::stdout().lock();
201            let _ = writeln!(stdout, "{}", obj.finish());
202        }
203        return exit::OK;
204    }
205
206    // Interactive: let the user reorder / drop / reword the todo before any
207    // mutation. Non-interactive: every commit is a plain pick.
208    let (todo, actions) = if interactive {
209        if candidates.is_empty() {
210            (Vec::new(), Vec::new())
211        } else {
212            match edit_todo(store, &candidates, orig_head, onto) {
213                Ok(plan) => plan,
214                Err(code) => return code,
215            }
216        }
217    } else {
218        let actions = vec![RebaseAction::Pick; candidates.len()];
219        (candidates, actions)
220    };
221    let state = RebaseState {
222        head_name,
223        orig_head,
224        onto,
225        todo,
226        actions,
227        done: Vec::new(),
228    };
229    let signing = match load_rebase_signing(layout) {
230        Ok(signing) => signing,
231        Err(code) => return code,
232    };
233    let onto_tree = match load_tree_hash(store, onto) {
234        Ok(t) => t,
235        Err(c) => return c,
236    };
237    if let Err(e) = super::ensure_restore_safe(layout, store, onto_tree) {
238        return emit_err(&e, exit::GENERAL_ERROR);
239    }
240    if let Err(e) = write_state(layout, &state) {
241        return emit_err(&format!("write rebase state: {e}"), exit::CANTCREAT);
242    }
243    // Start HEAD at `onto` and drive the replay.
244    if let Err(e) = super::restore_worktree_and_index(layout, store, onto_tree) {
245        return emit_err(&e, exit::GENERAL_ERROR);
246    }
247    if let Err(e) = refs::write_head_detached(layout, &onto) {
248        return emit_err(&format!("detach HEAD: {e}"), exit::CANTCREAT);
249    }
250    replay(layout, store, Some(signing), json)
251}
252
253/// Resume after a pause. When `skip` is set, drop the paused `todo[0]`
254/// with no replacement commit; otherwise create the rewritten commit
255/// for `todo[0]` from the resolved index, then keep replaying.
256fn resume(layout: &RepoLayout, store: &ObjectStore, skip: bool, json: bool) -> u8 {
257    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
258    if !is_rebase_in_progress(layout) {
259        return emit_err("no rebase in progress", exit::GENERAL_ERROR);
260    }
261    let rebase_dir = rebase_dir_path(layout);
262    let mut state = match read_state(layout) {
263        Ok(s) => s,
264        Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
265    };
266    let records = match conflict_state::read_conflicts(&rebase_dir) {
267        Ok(r) => r,
268        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
269    };
270
271    if skip {
272        if let Err(code) = skip_paused_commit(layout, store, &rebase_dir, &mut state, &records) {
273            return code;
274        }
275    } else if !records.is_empty()
276        && let Err(code) = commit_resolved_commit(layout, store, &rebase_dir, &mut state, &records)
277    {
278        return code;
279    }
280    // Either nothing was paused (plain resume) or we just consumed the
281    // paused commit; keep replaying the remaining todo.
282    replay(layout, store, None, json)
283}
284
285/// `--skip`: drop the paused `todo[0]` with no replacement, discarding
286/// its conflict material from the worktree/index.
287fn skip_paused_commit(
288    layout: &RepoLayout,
289    store: &ObjectStore,
290    rebase_dir: &std::path::Path,
291    state: &mut RebaseState,
292    records: &[conflict_state::ConflictRecord],
293) -> Result<(), u8> {
294    if state.todo.is_empty() {
295        return Err(emit_err(
296            "nothing to skip; no commit is paused",
297            exit::GENERAL_ERROR,
298        ));
299    }
300    let head_hash = match refs::resolve_head(layout) {
301        Ok(Some(h)) => h,
302        _ => state.onto,
303    };
304    let head_tree = load_tree_hash(store, head_hash)?;
305    // Also discard the skipped step's clean hunks (not just conflict paths).
306    let op_result = conflict_state::read_result_tree(rebase_dir).ok().flatten();
307    // Pre-flight before any mutation: refuse if discarding the step would
308    // destroy genuine user work — an edit to a cleanly-applied path, or
309    // unrelated staged/worktree changes — exactly as `--abort` does.
310    if let Err(e) = super::conflict::ensure_abort_safe(layout, store, records, head_tree, op_result)
311    {
312        return Err(emit_err(&e, exit::GENERAL_ERROR));
313    }
314    if let Err(e) =
315        super::conflict::reset_conflict_paths(layout, store, records, head_tree, op_result)
316    {
317        return Err(emit_err(&e, exit::GENERAL_ERROR));
318    }
319    state.consume_front();
320    persist_after_consume(layout, rebase_dir, state)
321}
322
323/// `--continue` on a paused commit: refuse if markers remain, build the
324/// rewritten commit's tree from the RESOLVED index (not the
325/// conflict-time tree), create the commit, and move `todo[0]` → `done`.
326fn commit_resolved_commit(
327    layout: &RepoLayout,
328    store: &ObjectStore,
329    rebase_dir: &std::path::Path,
330    state: &mut RebaseState,
331    records: &[conflict_state::ConflictRecord],
332) -> Result<(), u8> {
333    match super::conflict::first_unresolved_marker(layout.worktree_root(), records) {
334        Ok(Some(path)) => {
335            return Err(emit_err(
336                &format!(
337                    "unresolved conflict markers remain in '{path}'; resolve and `mkit add` it"
338                ),
339                exit::GENERAL_ERROR,
340            ));
341        }
342        Ok(None) => {}
343        Err(e) => return Err(emit_err(&e, exit::GENERAL_ERROR)),
344    }
345    if let Err(e) = super::conflict::ensure_conflict_paths_staged(layout, store, records) {
346        return Err(emit_err(&e, exit::GENERAL_ERROR));
347    }
348    if state.todo.is_empty() {
349        return Err(emit_err(
350            "rebase state is inconsistent: no paused commit",
351            exit::GENERAL_ERROR,
352        ));
353    }
354    let target = state.todo[0];
355    let head_hash = match refs::resolve_head(layout) {
356        Ok(Some(h)) => h,
357        _ => state.onto,
358    };
359    let idx = super::read_or_seed_index_from_head(layout, store)
360        .map_err(|e| emit_err(&e, exit::GENERAL_ERROR))?;
361    let tree_hash = worktree::build_tree_from_index(store, &idx)
362        .map_err(|e| emit_err(&format!("build tree from index: {e}"), exit::GENERAL_ERROR))?;
363    let mut signing = load_rebase_signing(layout)?;
364    // Same parent/message policy as the no-conflict path: pick/reword make a
365    // child of HEAD, squash/fixup fold into it (parent = HEAD's parent). The
366    // reword/squash editor opens now that the tree is resolved.
367    let plan = plan_step_commit(store, state.front_action(), target, head_hash)?;
368    let new_hash = build_commit(
369        store,
370        &mut signing.signer,
371        plan.author,
372        plan.timestamp,
373        plan.parent,
374        plan.message,
375        tree_hash,
376    )?;
377    // Sync the index to the committed tree WITHOUT rewriting the worktree:
378    // the tree was built from the index, so the worktree already holds the
379    // resolved content; restoring it would clobber unstaged edits made on a
380    // cleanly-replayed path before `--continue`.
381    if let Err(e) = super::sync_index_to_tree(layout, store, tree_hash) {
382        return Err(emit_err(&e, exit::GENERAL_ERROR));
383    }
384    if let Err(e) = refs::write_head_detached(layout, &new_hash) {
385        return Err(emit_err(&format!("update HEAD: {e}"), exit::CANTCREAT));
386    }
387    state.done.push(target);
388    state.consume_front();
389    persist_after_consume(layout, rebase_dir, state)
390}
391
392/// Clear the conflict sidecar and persist the updated rebase state.
393fn persist_after_consume(
394    layout: &RepoLayout,
395    rebase_dir: &std::path::Path,
396    state: &RebaseState,
397) -> Result<(), u8> {
398    if let Err(e) = conflict_state::write_conflicts(rebase_dir, &[]) {
399        return Err(emit_err(
400            &format!("clear conflicts: {e}"),
401            exit::GENERAL_ERROR,
402        ));
403    }
404    if let Err(e) = write_state(layout, state) {
405        return Err(emit_err(&format!("persist state: {e}"), exit::CANTCREAT));
406    }
407    Ok(())
408}
409
410fn abort(layout: &RepoLayout, store: &ObjectStore, json: bool) -> u8 {
411    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
412    if !is_rebase_in_progress(layout) {
413        return emit_err("no rebase in progress", exit::GENERAL_ERROR);
414    }
415    let state = match read_state(layout) {
416        Ok(s) => s,
417        Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
418    };
419    let orig_tree = match load_tree_hash(store, state.orig_head) {
420        Ok(tree) => tree,
421        Err(code) => return code,
422    };
423    // Discard any conflict material we materialised before guarding the
424    // restore (the sidecar lives inside the rebase-apply dir). Reset the
425    // recorded conflict paths to the CURRENT detached-HEAD tree so the
426    // worktree/index match HEAD (no spurious staged/local changes); the
427    // guarded restore below then moves cleanly back to orig_head.
428    let rebase_dir = rebase_dir_path(layout);
429    let records = match conflict_state::read_conflicts(&rebase_dir) {
430        Ok(r) => r,
431        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
432    };
433    // The paused step's result tree lets the guards treat its clean hunks
434    // (not just conflict paths) as discardable.
435    let op_result = conflict_state::read_result_tree(&rebase_dir).ok().flatten();
436    // Pre-flight: refuse before any mutation when the abort would clobber
437    // genuine user work on a non-discardable path. The reset below is
438    // destructive, so it must not run if the abort is going to be refused by
439    // the guarded restore. The final restore target is `orig_tree`, so the
440    // safety of non-discardable paths is judged against it.
441    if let Err(e) =
442        super::conflict::ensure_abort_safe(layout, store, &records, orig_tree, op_result)
443    {
444        return emit_err(&e, exit::GENERAL_ERROR);
445    }
446    if !records.is_empty() || op_result.is_some() {
447        let head_hash = match refs::resolve_head(layout) {
448            Ok(Some(h)) => h,
449            _ => state.onto,
450        };
451        let head_tree = match load_tree_hash(store, head_hash) {
452            Ok(t) => t,
453            Err(c) => return c,
454        };
455        if let Err(e) =
456            super::conflict::reset_conflict_paths(layout, store, &records, head_tree, op_result)
457        {
458            return emit_err(&e, exit::GENERAL_ERROR);
459        }
460    }
461    if let Err(e) = super::ensure_restore_safe(layout, store, orig_tree) {
462        return emit_err(&e, exit::GENERAL_ERROR);
463    }
464    if let Err(e) = super::restore_worktree_and_index(layout, store, orig_tree) {
465        return emit_err(&e, exit::GENERAL_ERROR);
466    }
467    // Rebase abort rolls the branch tip back to `orig_head`. Route
468    // through the history-MMR-coupled helper so the rollback append
469    // is recorded under the repo lock; the MMR is append-only, so
470    // "rollback" surfaces as another leaf, not a rewind.
471    if let Err(e) = super::write_ref_recording_history(
472        layout,
473        &state.head_name,
474        refs::RefWriteCondition::Any,
475        &state.orig_head,
476    ) {
477        return emit_err(&format!("restore ref: {e}"), exit::CANTCREAT);
478    }
479    if let Err(e) = refs::write_head_branch(layout, &state.head_name) {
480        return emit_err(&format!("restore HEAD: {e}"), exit::CANTCREAT);
481    }
482    let _ = cleanup_rebase(layout);
483    let mut stderr = std::io::stderr().lock();
484    let _ = writeln!(
485        stderr,
486        "rebase aborted; HEAD restored to {}",
487        &state.head_name
488    );
489    drop(stderr);
490    if json {
491        let mut obj = JsonObject::new();
492        obj.field_bool("ok", true)
493            .field_str("kind", "aborted")
494            .field_hash("hash", &state.orig_head);
495        let mut stdout = std::io::stdout().lock();
496        let _ = writeln!(stdout, "{}", obj.finish());
497    }
498    exit::OK
499}
500
501#[allow(clippy::too_many_lines)]
502fn replay(
503    layout: &RepoLayout,
504    store: &ObjectStore,
505    signing: Option<RebaseSigning>,
506    json: bool,
507) -> u8 {
508    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
509    let mut state = match read_state(layout) {
510        Ok(s) => s,
511        Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
512    };
513    let mut signing = match signing {
514        Some(signing) => signing,
515        None => match load_rebase_signing(layout) {
516            Ok(signing) => signing,
517            Err(code) => return code,
518        },
519    };
520    let rebase_dir = rebase_dir_path(layout);
521
522    while !state.todo.is_empty() {
523        // Clear any result tree left by a prior (now-resolved) conflict step
524        // so a later non-conflict pause (e.g. interactive `edit`) doesn't let
525        // `--abort`/`--skip` read a stale operation result.
526        conflict_state::clear_result_tree(&rebase_dir);
527        // Runtime leading-fold guard: a squash/fixup must fold into a commit
528        // that this rebase has already applied. The parse-time guard only runs
529        // when the todo is edited; `--skip`ping a conflicted leading pick can
530        // leave a squash/fixup as the first APPLIED step. `state.done` is
531        // empty iff nothing has been applied yet, so this fails closed BEFORE
532        // any mutation (HEAD still at its current step), preserving --abort.
533        if state.front_action().folds_into_previous() && state.done.is_empty() {
534            let verb = if state.front_action() == RebaseAction::Fixup {
535                "fixup"
536            } else {
537                "squash"
538            };
539            return emit_err(
540                &format!("cannot '{verb}' as the first commit; it has nothing to fold into"),
541                exit::USAGE,
542            );
543        }
544        let target = state.todo[0];
545        let head_hash = match refs::resolve_head(layout) {
546            Ok(Some(h)) => h,
547            _ => state.onto,
548        };
549        let ours_tree = match load_tree_hash(store, head_hash) {
550            Ok(t) => t,
551            Err(c) => return c,
552        };
553        // A replayed range can include MERGE commits (the first-parent walk
554        // keeps them). Core cherry-pick refuses a merge without a mainline,
555        // so replay merges against their first parent (`-m 1` semantics) —
556        // the historical behavior — instead of failing mid-rebase with the
557        // ref already moved.
558        let mainline = match store.read_object(&target) {
559            Ok(Object::Commit(c)) if c.parents.len() >= 2 => Some(1),
560            _ => None,
561        };
562        let result = match cherry_pick(store, target, ours_tree, mainline) {
563            Ok(r) => r,
564            Err(e) => return emit_err(&format!("cherry-pick: {e}"), exit::GENERAL_ERROR),
565        };
566        if result.has_conflicts() {
567            // Pause: persist state, materialise conflict material into
568            // the worktree + index, and write the sidecar so
569            // `--continue` consumes the resolved tree (not re-running
570            // cherry-pick).
571            let _ = write_state(layout, &state);
572            if let Err(e) = super::ensure_restore_safe(layout, store, result.tree_hash) {
573                return emit_err(&e, exit::GENERAL_ERROR);
574            }
575            let records = match super::conflict::materialize_conflicts(
576                layout,
577                store,
578                result.tree_hash,
579                &result.conflicts,
580            ) {
581                Ok(r) => r,
582                Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
583            };
584            if let Err(e) = conflict_state::write_conflicts(&rebase_dir, &records) {
585                return emit_err(&format!("write conflicts: {e}"), exit::CANTCREAT);
586            }
587            // Record the result tree so `--abort` treats this step's clean
588            // hunks (not just conflict paths) as discardable.
589            if let Err(e) = conflict_state::write_result_tree(&rebase_dir, &result.tree_hash) {
590                return emit_err(&format!("write conflicts: {e}"), exit::CANTCREAT);
591            }
592            let mut stderr = std::io::stderr().lock();
593            // git-shaped per-path conflict lines (additive).
594            for rec in &records {
595                let _ = writeln!(stderr, "CONFLICT (content): Merge conflict in {}", rec.path);
596            }
597            let _ = writeln!(
598                stderr,
599                "rebase paused: conflict while replaying {}",
600                format::short_hash(&target, 8)
601            );
602            let _ = writeln!(
603                stderr,
604                "resolve the files above, `mkit add` them, then run `mkit rebase --continue` \
605                 (or `--skip` to drop this commit, or `--abort`)"
606            );
607            drop(stderr);
608            if json {
609                let paths: Vec<&str> = records.iter().map(|r| r.path.as_str()).collect();
610                let mut obj = JsonObject::new();
611                obj.field_bool("ok", false)
612                    .field_str("kind", "conflict")
613                    .field_hash("replaying", &target)
614                    .field_raw("conflicts", &json_string_array(&paths))
615                    .field_str("error", "rebase paused: conflict while replaying");
616                let mut stdout = std::io::stdout().lock();
617                let _ = writeln!(stdout, "{}", obj.finish());
618            }
619            return exit::GENERAL_ERROR;
620        }
621        if let Err(e) = super::ensure_restore_safe(layout, store, result.tree_hash) {
622            return emit_err(&e, exit::GENERAL_ERROR);
623        }
624        // Compute the new commit's parent + message for this action (pick/
625        // reword make a child of HEAD; squash/fixup fold into HEAD). Any
626        // editor (reword/squash) runs here, after the tree is clean — the
627        // conflict-resume path does the same in `commit_resolved_commit`.
628        let plan = match plan_step_commit(store, state.front_action(), target, head_hash) {
629            Ok(p) => p,
630            Err(c) => return c,
631        };
632        let new_hash = match build_commit(
633            store,
634            &mut signing.signer,
635            plan.author,
636            plan.timestamp,
637            plan.parent,
638            plan.message,
639            result.tree_hash,
640        ) {
641            Ok(h) => h,
642            Err(c) => return c,
643        };
644        if let Err(e) = super::restore_worktree_and_index(layout, store, result.tree_hash) {
645            return emit_err(&e, exit::GENERAL_ERROR);
646        }
647        if let Err(e) = refs::write_head_detached(layout, &new_hash) {
648            return emit_err(&format!("update HEAD: {e}"), exit::CANTCREAT);
649        }
650        state.done.push(target);
651        state.consume_front();
652        if let Err(e) = write_state(layout, &state) {
653            return emit_err(&format!("persist state: {e}"), exit::CANTCREAT);
654        }
655    }
656
657    // Finish: move the branch to current HEAD and reattach. HEAD is
658    // detached to a hash for the entire rebase (start detaches to `onto`,
659    // each replay advances it), so a finalized rebase ALWAYS resolves to
660    // `Some` — even an empty rebase leaves HEAD at `onto`. `None`/`Err`
661    // therefore means HEAD was lost or corrupted mid-rebase: fail closed
662    // rather than silently move the branch to `onto` and drop the
663    // replayed tip.
664    let final_head = match refs::resolve_head(layout) {
665        Ok(Some(h)) => h,
666        Ok(None) => {
667            return emit_err(
668                "rebase: HEAD missing at finalize (in-progress state may be corrupted); aborting",
669                exit::DATAERR,
670            );
671        }
672        Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::DATAERR),
673    };
674    // The original tip is superseded by the replayed history. Record it
675    // BEFORE finalizing the branch (still under the worktree lock) so it
676    // survives gc once the in-progress rebase state — which currently
677    // pins it — is cleaned up below. Abort if the log can't be written.
678    if state.orig_head != final_head
679        && let Err((m, c)) =
680            super::record_superseded(layout, "rebase", &state.head_name, state.orig_head)
681    {
682        return emit_err(&m, c);
683    }
684    if let Err(e) = super::write_ref_recording_history(
685        layout,
686        &state.head_name,
687        refs::RefWriteCondition::Any,
688        &final_head,
689    ) {
690        return emit_err(&format!("write ref: {e}"), exit::CANTCREAT);
691    }
692    if let Err(e) = refs::write_head_branch(layout, &state.head_name) {
693        return emit_err(&format!("reattach HEAD: {e}"), exit::CANTCREAT);
694    }
695    let _ = cleanup_rebase(layout);
696    let mut stderr = std::io::stderr().lock();
697    let _ = writeln!(
698        stderr,
699        "Successfully rebased and updated refs/heads/{}.",
700        state.head_name
701    );
702    drop(stderr);
703    if json {
704        let mut obj = JsonObject::new();
705        obj.field_bool("ok", true)
706            .field_str("kind", "rebased")
707            .field_str("branch", &state.head_name)
708            .field_hash("old", &state.orig_head)
709            .field_hash("new", &final_head)
710            .field_u64("commits_replayed", state.done.len() as u64);
711        let mut stdout = std::io::stdout().lock();
712        let _ = writeln!(stdout, "{}", obj.finish());
713    }
714    exit::OK
715}
716
717struct RebaseSigning {
718    signer: super::commit::CommitSigner,
719}
720
721fn load_rebase_signing(layout: &RepoLayout) -> Result<RebaseSigning, u8> {
722    let cfg = config::read_or_default(layout)
723        .map_err(|e| emit_err(&format!("config: {e}"), exit::CONFIG_ERROR))?;
724    let signer = super::commit::load_commit_signer(layout, &cfg)
725        .map_err(|(msg, code)| emit_err(&msg, code))?;
726    Ok(RebaseSigning { signer })
727}
728
729fn build_commit(
730    store: &ObjectStore,
731    signer: &mut super::commit::CommitSigner,
732    author: Identity,
733    timestamp: u64,
734    parent: Hash,
735    message: Vec<u8>,
736    tree_hash: Hash,
737) -> Result<Hash, u8> {
738    let signer_public = signer
739        .public_key()
740        .map_err(|(msg, code)| emit_err(&msg, code))?;
741    let mut unsigned = Commit::new_unannotated(
742        tree_hash,
743        vec![parent],
744        author,
745        signer_public,
746        message,
747        timestamp,
748        [0u8; 64],
749    );
750    let sig = signer
751        .sign_commit(&unsigned)
752        .map_err(|(msg, code)| emit_err(&msg, code))?;
753    unsigned.signature = sig;
754    let bytes = serialize::serialize(&Object::Commit(unsigned))
755        .map_err(|e| emit_err(&format!("serialize: {e}"), exit::DATAERR))?;
756    store
757        .write(&bytes)
758        .map_err(|e| emit_err(&format!("store: {e}"), exit::CANTCREAT))
759}
760
761fn load_tree_hash(store: &ObjectStore, commit_hash: Hash) -> Result<Hash, u8> {
762    match store.read_object(&commit_hash) {
763        Ok(Object::Commit(c)) => Ok(c.tree_hash),
764        Ok(_) => Err(emit_err("object is not a commit", exit::DATAERR)),
765        Err(e) => Err(emit_err(&format!("read commit: {e}"), exit::GENERAL_ERROR)),
766    }
767}
768
769/// The parent and message a replayed commit gets under `action`.
770///
771/// `pick`/`reword` create a NEW commit as a child of `head_hash`.
772/// `squash`/`fixup` **fold** the target into `head_hash`: the new commit
773/// replaces it, so its parent is HEAD's own parent and the message combines
774/// (`squash`) or is kept from HEAD (`fixup`). Both the no-conflict replay
775/// and the `--continue` resume path call this, so they cannot diverge.
776struct StepCommit {
777    parent: Hash,
778    message: Vec<u8>,
779    /// Replayed commits keep the original authorship: pick/reword use
780    /// the target's author + timestamp; squash/fixup keep the folded-
781    /// into commit's (git's behavior — replays re-sign but never
782    /// re-attribute, and mkit's single timestamp takes author-date
783    /// semantics on replay).
784    author: Identity,
785    timestamp: u64,
786}
787
788fn plan_step_commit(
789    store: &ObjectStore,
790    action: RebaseAction,
791    target: Hash,
792    head_hash: Hash,
793) -> Result<StepCommit, u8> {
794    match action {
795        RebaseAction::Pick => {
796            let original = read_commit(store, target)?;
797            Ok(StepCommit {
798                parent: head_hash,
799                message: original.message,
800                author: original.author,
801                timestamp: original.timestamp,
802            })
803        }
804        RebaseAction::Reword => {
805            let original = read_commit(store, target)?;
806            Ok(StepCommit {
807                parent: head_hash,
808                message: reworded_message(&original.message)?,
809                author: original.author,
810                timestamp: original.timestamp,
811            })
812        }
813        RebaseAction::Squash | RebaseAction::Fixup => {
814            // Fold into HEAD: the new commit takes HEAD's place, so its
815            // parent is HEAD's parent. A squash/fixup is rejected at parse
816            // time when it would be the first applied commit, so HEAD here is
817            // always a just-built commit with exactly one parent.
818            let head_commit = read_commit(store, head_hash)?;
819            let parent = head_commit.parents.first().copied().ok_or_else(|| {
820                emit_err(
821                    "'squash'/'fixup' has no preceding commit to fold into",
822                    exit::DATAERR,
823                )
824            })?;
825            let message = if action == RebaseAction::Fixup {
826                head_commit.message.clone()
827            } else {
828                let target_msg = read_commit(store, target)?.message;
829                squashed_message(&head_commit.message, &target_msg)?
830            };
831            Ok(StepCommit {
832                parent,
833                message,
834                author: head_commit.author,
835                timestamp: head_commit.timestamp,
836            })
837        }
838    }
839}
840
841fn read_commit(store: &ObjectStore, h: Hash) -> Result<Commit, u8> {
842    match store.read_object(&h) {
843        Ok(Object::Commit(c)) => Ok(c),
844        Ok(_) => Err(emit_err("object is not a commit", exit::DATAERR)),
845        Err(e) => Err(emit_err(&format!("read commit: {e}"), exit::GENERAL_ERROR)),
846    }
847}
848
849/// Open the editor on a reword seed; an empty result keeps the original
850/// message rather than aborting the rebase.
851fn reworded_message(original: &[u8]) -> Result<Vec<u8>, u8> {
852    let seed = reword_template(original);
853    match editor::spawn_editor(&seed) {
854        Ok(s) if !s.trim().is_empty() => Ok(s.into_bytes()),
855        Ok(_) => {
856            let mut stderr = std::io::stderr().lock();
857            let _ = writeln!(stderr, "reword: empty message; keeping the original");
858            Ok(original.to_vec())
859        }
860        Err(e) => Err(emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR)),
861    }
862}
863
864/// Combine the kept commit's message with the squashed commit's via the
865/// editor. An empty result falls back to plain concatenation (never aborts).
866fn squashed_message(head_msg: &[u8], target_msg: &[u8]) -> Result<Vec<u8>, u8> {
867    let seed = format!(
868        "{}\n\n{}\n\n\
869         # This is a combination of 2 commits; the first message is the one\n\
870         # being squashed into. Edit the combined message above. Lines\n\
871         # starting with '#' are ignored.\n",
872        String::from_utf8_lossy(head_msg),
873        String::from_utf8_lossy(target_msg),
874    );
875    match editor::spawn_editor(&seed) {
876        Ok(s) if !s.trim().is_empty() => Ok(s.into_bytes()),
877        Ok(_) => {
878            let mut combined = head_msg.to_vec();
879            combined.extend_from_slice(b"\n\n");
880            combined.extend_from_slice(target_msg);
881            Ok(combined)
882        }
883        Err(e) => Err(emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR)),
884    }
885}
886
887/// Editor seed for a reword: the original message followed by ignored
888/// `#`-comment guidance (stripped on read by `spawn_editor`).
889fn reword_template(original: &[u8]) -> String {
890    format!(
891        "{}\n\
892         # Reword: edit the commit message above. Lines starting with '#'\n\
893         # are ignored. An empty message keeps the original message.\n",
894        String::from_utf8_lossy(original)
895    )
896}
897
898/// First line of a commit's message, for the interactive todo display.
899fn commit_subject(store: &ObjectStore, h: Hash) -> String {
900    match store.read_object(&h) {
901        Ok(Object::Commit(c)) => {
902            let text = String::from_utf8_lossy(&c.message);
903            text.lines().next().unwrap_or("").trim().to_string()
904        }
905        _ => String::new(),
906    }
907}
908
909/// Render the interactive todo from a non-empty candidate list, open the
910/// editor, and parse the result into a `(todo, actions)` plan in the edited
911/// order. The returned `todo` may be empty if the user dropped every line
912/// (which resets the branch to the base). Mutating nothing, it is safe to
913/// fail here before the rebase touches HEAD. (The empty-candidate case is
914/// handled by the caller.)
915#[allow(clippy::type_complexity)]
916fn edit_todo(
917    store: &ObjectStore,
918    candidates: &[Hash],
919    orig_head: Hash,
920    onto: Hash,
921) -> Result<(Vec<Hash>, Vec<RebaseAction>), u8> {
922    use std::fmt::Write as _;
923    // Build the template: one `pick <short> <subject>` line per candidate,
924    // oldest-first (the order `collect_commits_to_replay` returns).
925    let mut template = String::new();
926    for h in candidates {
927        let _ = writeln!(
928            template,
929            "pick {} {}",
930            format::short_hash(h, 12),
931            commit_subject(store, *h)
932        );
933    }
934    let _ = write!(
935        template,
936        "\n\
937         # Rebase {}..{} onto {}.\n\
938         #\n\
939         # Commands (one per line, in apply order — top is applied first):\n\
940         #   p, pick   <commit>  = use the commit\n\
941         #   r, reword <commit>  = use the commit, but edit its message\n\
942         #   s, squash <commit>  = fold into the previous commit, combining messages\n\
943         #   f, fixup  <commit>  = fold into the previous commit, discard this message\n\
944         #   d, drop   <commit>  = remove the commit\n\
945         #\n\
946         # Reorder lines to reorder commits. Deleting a line drops that commit.\n\
947         # A squash/fixup cannot be the first line. 'edit' is not yet supported.\n\
948         # Removing every line resets the branch to the base.\n",
949        format::short_hash(&onto, 12),
950        format::short_hash(&orig_head, 12),
951        format::short_hash(&onto, 12),
952    );
953
954    let edited = editor::spawn_editor(&template).map_err(|e| {
955        // spawn_editor strips comment lines, so the seed text never counts as
956        // "content"; an editor failure is the only real error here.
957        emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR)
958    })?;
959
960    parse_todo(candidates, &edited)
961}
962
963/// Parse the edited todo text into `(todo, actions)`. Validates verbs and
964/// resolves each abbreviated commit against `candidates`. Fails (before any
965/// mutation) on an unknown verb, an unknown/ambiguous commit, the still-
966/// unsupported `edit` verb, or a leading `squash`/`fixup` (which has no
967/// preceding commit to fold into).
968#[allow(clippy::type_complexity)]
969fn parse_todo(candidates: &[Hash], edited: &str) -> Result<(Vec<Hash>, Vec<RebaseAction>), u8> {
970    let mut todo = Vec::new();
971    let mut actions = Vec::new();
972    for raw in edited.lines() {
973        let line = raw.trim();
974        if line.is_empty() || line.starts_with('#') {
975            continue;
976        }
977        let mut parts = line.split_whitespace();
978        let verb = parts.next().unwrap_or("");
979        let action = match verb {
980            "p" | "pick" => RebaseAction::Pick,
981            "r" | "reword" => RebaseAction::Reword,
982            "s" | "squash" => RebaseAction::Squash,
983            "f" | "fixup" => RebaseAction::Fixup,
984            "d" | "drop" => {
985                // Dropped: still validate the hash so a typo is caught, then
986                // omit the commit.
987                let _ = resolve_todo_hash(candidates, parts.next(), line)?;
988                continue;
989            }
990            "e" | "edit" => {
991                return Err(emit_err(
992                    "'edit' (stop to amend) is not yet supported; use pick, reword, squash, fixup, or drop",
993                    exit::USAGE,
994                ));
995            }
996            other => {
997                return Err(emit_err(
998                    &format!("unknown rebase command '{other}'"),
999                    exit::USAGE,
1000                ));
1001            }
1002        };
1003        // A squash/fixup folds into the previous commit, so it cannot be the
1004        // first applied line (git: "cannot 'squash' without a previous
1005        // commit"). Reject before any mutation.
1006        if todo.is_empty() && action.folds_into_previous() {
1007            return Err(emit_err(
1008                &format!("cannot '{verb}' as the first commit; it has nothing to fold into"),
1009                exit::USAGE,
1010            ));
1011        }
1012        let h = resolve_todo_hash(candidates, parts.next(), line)?;
1013        todo.push(h);
1014        actions.push(action);
1015    }
1016    Ok((todo, actions))
1017}
1018
1019/// Resolve an abbreviated commit token from a todo line against the original
1020/// candidate set (unambiguous prefix match or full hash).
1021fn resolve_todo_hash(candidates: &[Hash], token: Option<&str>, line: &str) -> Result<Hash, u8> {
1022    let token = token.ok_or_else(|| {
1023        emit_err(
1024            &format!("missing commit on todo line: '{line}'"),
1025            exit::USAGE,
1026        )
1027    })?;
1028    let token = token.to_ascii_lowercase();
1029    let matches: Vec<&Hash> = candidates
1030        .iter()
1031        .filter(|h| mkit_core::hash::to_hex(h).starts_with(&token))
1032        .collect();
1033    match matches.as_slice() {
1034        [h] => Ok(**h),
1035        [] => Err(emit_err(
1036            &format!("todo line refers to an unknown commit: '{line}'"),
1037            exit::USAGE,
1038        )),
1039        _ => Err(emit_err(
1040            &format!("ambiguous commit '{token}' on todo line: '{line}'"),
1041            exit::USAGE,
1042        )),
1043    }
1044}
1045
1046use super::error as emit_err;