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