use std::io::Write;
use mkit_core::hash::Hash;
use mkit_core::layout::RepoLayout;
use mkit_core::object::{Commit, Identity, Object};
use mkit_core::ops::cherry_pick::cherry_pick;
use mkit_core::ops::conflict_state::{self, in_progress_op_name};
use mkit_core::ops::rebase::{
RebaseAction, RebaseState, cleanup_rebase, collect_commits_to_replay, is_rebase_in_progress,
read_state, rebase_dir_path, write_state,
};
use mkit_core::refs::{self, Head};
use mkit_core::serialize;
use mkit_core::store::ObjectStore;
use mkit_core::worktree;
use clap::{Parser, ValueEnum};
use crate::clap_shim;
use crate::config;
use crate::editor;
use crate::exit;
use crate::format::{self, JsonObject, json_string_array};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum RebaseFormat {
Default,
Json,
}
#[derive(Debug, Parser)]
#[command(name = "mkit rebase", about = "Replay commits onto a different base.")]
#[allow(clippy::struct_excessive_bools)]
struct RebaseOpts {
#[arg(long = "continue", conflicts_with_all = ["abort", "skip", "branch"])]
cont: bool,
#[arg(long, conflicts_with_all = ["cont", "skip", "branch"])]
abort: bool,
#[arg(long, conflicts_with_all = ["cont", "abort", "branch"])]
skip: bool,
#[arg(short = 'i', long, conflicts_with_all = ["cont", "abort", "skip"])]
interactive: bool,
#[arg(long, value_enum, default_value = "default")]
format: RebaseFormat,
branch: Option<String>,
}
fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
if json {
let mut obj = JsonObject::new();
obj.field_bool("ok", false).field_str("error", msg);
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{}", obj.finish());
}
emit_err(msg, code)
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<RebaseOpts>("mkit rebase", args) {
Ok(o) => o,
Err(code) => return code,
};
let json = matches!(opts.format, RebaseFormat::Json);
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
};
let layout = match super::resolve_layout(&cwd) {
Ok(layout) => layout,
Err(code) => return code,
};
let store = match ObjectStore::open(&layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
};
let _lock = match super::acquire_worktree_lock(&layout) {
Ok(l) => l,
Err(code) => return code,
};
if opts.abort {
abort(&layout, &store, json)
} else if opts.cont {
resume(&layout, &store, false, json)
} else if opts.skip {
resume(&layout, &store, true, json)
} else if let Some(branch) = opts.branch.as_deref() {
start(&layout, &store, branch, opts.interactive, json)
} else {
super::usage_error("usage: mkit rebase [-i] <revspec> | --continue | --abort | --skip")
}
}
fn start(
layout: &RepoLayout,
store: &ObjectStore,
branch: &str,
interactive: bool,
json: bool,
) -> u8 {
let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
if let Some(op) = in_progress_op_name(layout) {
return emit_err(
&format!("a {op} is already in progress (use --continue or --abort)"),
exit::GENERAL_ERROR,
);
}
let onto = match super::revspec::resolve_revision(store, layout, branch) {
Ok(h) => h,
Err(e) => {
return emit_err(
&format!("no such commit: {branch} ({e})"),
exit::GENERAL_ERROR,
);
}
};
let orig_head = match refs::resolve_head(layout) {
Ok(Some(h)) => h,
Ok(None) => return emit_err("no commits on current branch", exit::GENERAL_ERROR),
Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
};
let head_name = match refs::read_head(layout) {
Ok(Head::Branch(name)) => name,
Ok(Head::Detached(_)) => {
return emit_err("cannot rebase with detached HEAD", exit::GENERAL_ERROR);
}
Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::GENERAL_ERROR),
};
let candidates = match collect_commits_to_replay(store, orig_head, onto) {
Ok(v) => v,
Err(e) => return emit_err(&format!("collect commits: {e}"), exit::GENERAL_ERROR),
};
if orig_head == onto {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "Current branch {head_name} is up to date.");
drop(stderr);
if json {
let mut obj = JsonObject::new();
obj.field_bool("ok", true)
.field_str("kind", "up-to-date")
.field_hash("hash", &orig_head);
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{}", obj.finish());
}
return exit::OK;
}
let (todo, actions) = if interactive {
if candidates.is_empty() {
(Vec::new(), Vec::new())
} else {
match edit_todo(store, &candidates, orig_head, onto) {
Ok(plan) => plan,
Err(code) => return code,
}
}
} else {
let actions = vec![RebaseAction::Pick; candidates.len()];
(candidates, actions)
};
let state = RebaseState {
head_name,
orig_head,
onto,
todo,
actions,
done: Vec::new(),
};
let signing = match load_rebase_signing(layout) {
Ok(signing) => signing,
Err(code) => return code,
};
let onto_tree = match load_tree_hash(store, onto) {
Ok(t) => t,
Err(c) => return c,
};
if let Err(e) = super::ensure_restore_safe(layout, store, onto_tree) {
return emit_err(&e, exit::GENERAL_ERROR);
}
if let Err(e) = write_state(layout, &state) {
return emit_err(&format!("write rebase state: {e}"), exit::CANTCREAT);
}
if let Err(e) = super::restore_worktree_and_index(layout, store, onto_tree) {
return emit_err(&e, exit::GENERAL_ERROR);
}
if let Err(e) = refs::write_head_detached(layout, &onto) {
return emit_err(&format!("detach HEAD: {e}"), exit::CANTCREAT);
}
replay(layout, store, Some(signing), json)
}
fn resume(layout: &RepoLayout, store: &ObjectStore, skip: bool, json: bool) -> u8 {
let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
if !is_rebase_in_progress(layout) {
return emit_err("no rebase in progress", exit::GENERAL_ERROR);
}
let rebase_dir = rebase_dir_path(layout);
let mut state = match read_state(layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
};
let records = match conflict_state::read_conflicts(&rebase_dir) {
Ok(r) => r,
Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
};
if skip {
if let Err(code) = skip_paused_commit(layout, store, &rebase_dir, &mut state, &records) {
return code;
}
} else if !records.is_empty()
&& let Err(code) = commit_resolved_commit(layout, store, &rebase_dir, &mut state, &records)
{
return code;
}
replay(layout, store, None, json)
}
fn skip_paused_commit(
layout: &RepoLayout,
store: &ObjectStore,
rebase_dir: &std::path::Path,
state: &mut RebaseState,
records: &[conflict_state::ConflictRecord],
) -> Result<(), u8> {
if state.todo.is_empty() {
return Err(emit_err(
"nothing to skip; no commit is paused",
exit::GENERAL_ERROR,
));
}
let head_hash = match refs::resolve_head(layout) {
Ok(Some(h)) => h,
_ => state.onto,
};
let head_tree = load_tree_hash(store, head_hash)?;
let op_result = conflict_state::read_result_tree(rebase_dir).ok().flatten();
if let Err(e) = super::conflict::ensure_abort_safe(layout, store, records, head_tree, op_result)
{
return Err(emit_err(&e, exit::GENERAL_ERROR));
}
if let Err(e) =
super::conflict::reset_conflict_paths(layout, store, records, head_tree, op_result)
{
return Err(emit_err(&e, exit::GENERAL_ERROR));
}
state.consume_front();
persist_after_consume(layout, rebase_dir, state)
}
fn commit_resolved_commit(
layout: &RepoLayout,
store: &ObjectStore,
rebase_dir: &std::path::Path,
state: &mut RebaseState,
records: &[conflict_state::ConflictRecord],
) -> Result<(), u8> {
match super::conflict::first_unresolved_marker(layout.worktree_root(), records) {
Ok(Some(path)) => {
return Err(emit_err(
&format!(
"unresolved conflict markers remain in '{path}'; resolve and `mkit add` it"
),
exit::GENERAL_ERROR,
));
}
Ok(None) => {}
Err(e) => return Err(emit_err(&e, exit::GENERAL_ERROR)),
}
if let Err(e) = super::conflict::ensure_conflict_paths_staged(layout, store, records) {
return Err(emit_err(&e, exit::GENERAL_ERROR));
}
if state.todo.is_empty() {
return Err(emit_err(
"rebase state is inconsistent: no paused commit",
exit::GENERAL_ERROR,
));
}
let target = state.todo[0];
let head_hash = match refs::resolve_head(layout) {
Ok(Some(h)) => h,
_ => state.onto,
};
let idx = super::read_or_seed_index_from_head(layout, store)
.map_err(|e| emit_err(&e, exit::GENERAL_ERROR))?;
let tree_hash = worktree::build_tree_from_index(store, &idx)
.map_err(|e| emit_err(&format!("build tree from index: {e}"), exit::GENERAL_ERROR))?;
let mut signing = load_rebase_signing(layout)?;
let plan = plan_step_commit(store, state.front_action(), target, head_hash)?;
let new_hash = build_commit(
store,
&mut signing.signer,
plan.author,
plan.timestamp,
plan.parent,
plan.message,
tree_hash,
)?;
if let Err(e) = super::sync_index_to_tree(layout, store, tree_hash) {
return Err(emit_err(&e, exit::GENERAL_ERROR));
}
if let Err(e) = refs::write_head_detached(layout, &new_hash) {
return Err(emit_err(&format!("update HEAD: {e}"), exit::CANTCREAT));
}
state.done.push(target);
state.consume_front();
persist_after_consume(layout, rebase_dir, state)
}
fn persist_after_consume(
layout: &RepoLayout,
rebase_dir: &std::path::Path,
state: &RebaseState,
) -> Result<(), u8> {
if let Err(e) = conflict_state::write_conflicts(rebase_dir, &[]) {
return Err(emit_err(
&format!("clear conflicts: {e}"),
exit::GENERAL_ERROR,
));
}
if let Err(e) = write_state(layout, state) {
return Err(emit_err(&format!("persist state: {e}"), exit::CANTCREAT));
}
Ok(())
}
fn abort(layout: &RepoLayout, store: &ObjectStore, json: bool) -> u8 {
let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
if !is_rebase_in_progress(layout) {
return emit_err("no rebase in progress", exit::GENERAL_ERROR);
}
let state = match read_state(layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
};
let orig_tree = match load_tree_hash(store, state.orig_head) {
Ok(tree) => tree,
Err(code) => return code,
};
let rebase_dir = rebase_dir_path(layout);
let records = match conflict_state::read_conflicts(&rebase_dir) {
Ok(r) => r,
Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
};
let op_result = conflict_state::read_result_tree(&rebase_dir).ok().flatten();
if let Err(e) =
super::conflict::ensure_abort_safe(layout, store, &records, orig_tree, op_result)
{
return emit_err(&e, exit::GENERAL_ERROR);
}
if !records.is_empty() || op_result.is_some() {
let head_hash = match refs::resolve_head(layout) {
Ok(Some(h)) => h,
_ => state.onto,
};
let head_tree = match load_tree_hash(store, head_hash) {
Ok(t) => t,
Err(c) => return c,
};
if let Err(e) =
super::conflict::reset_conflict_paths(layout, store, &records, head_tree, op_result)
{
return emit_err(&e, exit::GENERAL_ERROR);
}
}
if let Err(e) = super::ensure_restore_safe(layout, store, orig_tree) {
return emit_err(&e, exit::GENERAL_ERROR);
}
if let Err(e) = super::restore_worktree_and_index(layout, store, orig_tree) {
return emit_err(&e, exit::GENERAL_ERROR);
}
if let Err(e) = super::write_ref_recording_history(
layout,
&state.head_name,
refs::RefWriteCondition::Any,
&state.orig_head,
) {
return emit_err(&format!("restore ref: {e}"), exit::CANTCREAT);
}
if let Err(e) = refs::write_head_branch(layout, &state.head_name) {
return emit_err(&format!("restore HEAD: {e}"), exit::CANTCREAT);
}
let _ = cleanup_rebase(layout);
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"rebase aborted; HEAD restored to {}",
&state.head_name
);
drop(stderr);
if json {
let mut obj = JsonObject::new();
obj.field_bool("ok", true)
.field_str("kind", "aborted")
.field_hash("hash", &state.orig_head);
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{}", obj.finish());
}
exit::OK
}
#[allow(clippy::too_many_lines)]
fn replay(
layout: &RepoLayout,
store: &ObjectStore,
signing: Option<RebaseSigning>,
json: bool,
) -> u8 {
let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
let mut state = match read_state(layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
};
let mut signing = match signing {
Some(signing) => signing,
None => match load_rebase_signing(layout) {
Ok(signing) => signing,
Err(code) => return code,
},
};
let rebase_dir = rebase_dir_path(layout);
while !state.todo.is_empty() {
conflict_state::clear_result_tree(&rebase_dir);
if state.front_action().folds_into_previous() && state.done.is_empty() {
let verb = if state.front_action() == RebaseAction::Fixup {
"fixup"
} else {
"squash"
};
return emit_err(
&format!("cannot '{verb}' as the first commit; it has nothing to fold into"),
exit::USAGE,
);
}
let target = state.todo[0];
let head_hash = match refs::resolve_head(layout) {
Ok(Some(h)) => h,
_ => state.onto,
};
let ours_tree = match load_tree_hash(store, head_hash) {
Ok(t) => t,
Err(c) => return c,
};
let mainline = match store.read_object(&target) {
Ok(Object::Commit(c)) if c.parents.len() >= 2 => Some(1),
_ => None,
};
let result = match cherry_pick(store, target, ours_tree, mainline) {
Ok(r) => r,
Err(e) => return emit_err(&format!("cherry-pick: {e}"), exit::GENERAL_ERROR),
};
if result.has_conflicts() {
let _ = write_state(layout, &state);
if let Err(e) = super::ensure_restore_safe(layout, store, result.tree_hash) {
return emit_err(&e, exit::GENERAL_ERROR);
}
let records = match super::conflict::materialize_conflicts(
layout,
store,
result.tree_hash,
&result.conflicts,
) {
Ok(r) => r,
Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
};
if let Err(e) = conflict_state::write_conflicts(&rebase_dir, &records) {
return emit_err(&format!("write conflicts: {e}"), exit::CANTCREAT);
}
if let Err(e) = conflict_state::write_result_tree(&rebase_dir, &result.tree_hash) {
return emit_err(&format!("write conflicts: {e}"), exit::CANTCREAT);
}
let mut stderr = std::io::stderr().lock();
for rec in &records {
let _ = writeln!(stderr, "CONFLICT (content): Merge conflict in {}", rec.path);
}
let _ = writeln!(
stderr,
"rebase paused: conflict while replaying {}",
format::short_hash(&target, 8)
);
let _ = writeln!(
stderr,
"resolve the files above, `mkit add` them, then run `mkit rebase --continue` \
(or `--skip` to drop this commit, or `--abort`)"
);
drop(stderr);
if json {
let paths: Vec<&str> = records.iter().map(|r| r.path.as_str()).collect();
let mut obj = JsonObject::new();
obj.field_bool("ok", false)
.field_str("kind", "conflict")
.field_hash("replaying", &target)
.field_raw("conflicts", &json_string_array(&paths))
.field_str("error", "rebase paused: conflict while replaying");
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{}", obj.finish());
}
return exit::GENERAL_ERROR;
}
if let Err(e) = super::ensure_restore_safe(layout, store, result.tree_hash) {
return emit_err(&e, exit::GENERAL_ERROR);
}
let plan = match plan_step_commit(store, state.front_action(), target, head_hash) {
Ok(p) => p,
Err(c) => return c,
};
let new_hash = match build_commit(
store,
&mut signing.signer,
plan.author,
plan.timestamp,
plan.parent,
plan.message,
result.tree_hash,
) {
Ok(h) => h,
Err(c) => return c,
};
if let Err(e) = super::restore_worktree_and_index(layout, store, result.tree_hash) {
return emit_err(&e, exit::GENERAL_ERROR);
}
if let Err(e) = refs::write_head_detached(layout, &new_hash) {
return emit_err(&format!("update HEAD: {e}"), exit::CANTCREAT);
}
state.done.push(target);
state.consume_front();
if let Err(e) = write_state(layout, &state) {
return emit_err(&format!("persist state: {e}"), exit::CANTCREAT);
}
}
let final_head = match refs::resolve_head(layout) {
Ok(Some(h)) => h,
Ok(None) => {
return emit_err(
"rebase: HEAD missing at finalize (in-progress state may be corrupted); aborting",
exit::DATAERR,
);
}
Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::DATAERR),
};
if state.orig_head != final_head
&& let Err((m, c)) =
super::record_superseded(layout, "rebase", &state.head_name, state.orig_head)
{
return emit_err(&m, c);
}
if let Err(e) = super::write_ref_recording_history(
layout,
&state.head_name,
refs::RefWriteCondition::Any,
&final_head,
) {
return emit_err(&format!("write ref: {e}"), exit::CANTCREAT);
}
if let Err(e) = refs::write_head_branch(layout, &state.head_name) {
return emit_err(&format!("reattach HEAD: {e}"), exit::CANTCREAT);
}
let _ = cleanup_rebase(layout);
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"Successfully rebased and updated refs/heads/{}.",
state.head_name
);
drop(stderr);
if json {
let mut obj = JsonObject::new();
obj.field_bool("ok", true)
.field_str("kind", "rebased")
.field_str("branch", &state.head_name)
.field_hash("old", &state.orig_head)
.field_hash("new", &final_head)
.field_u64("commits_replayed", state.done.len() as u64);
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{}", obj.finish());
}
exit::OK
}
struct RebaseSigning {
signer: super::commit::CommitSigner,
}
fn load_rebase_signing(layout: &RepoLayout) -> Result<RebaseSigning, u8> {
let cfg = config::read_or_default(layout)
.map_err(|e| emit_err(&format!("config: {e}"), exit::CONFIG_ERROR))?;
let signer = super::commit::load_commit_signer(layout, &cfg)
.map_err(|(msg, code)| emit_err(&msg, code))?;
Ok(RebaseSigning { signer })
}
fn build_commit(
store: &ObjectStore,
signer: &mut super::commit::CommitSigner,
author: Identity,
timestamp: u64,
parent: Hash,
message: Vec<u8>,
tree_hash: Hash,
) -> Result<Hash, u8> {
let signer_public = signer
.public_key()
.map_err(|(msg, code)| emit_err(&msg, code))?;
let mut unsigned = Commit::new_unannotated(
tree_hash,
vec![parent],
author,
signer_public,
message,
timestamp,
[0u8; 64],
);
let sig = signer
.sign_commit(&unsigned)
.map_err(|(msg, code)| emit_err(&msg, code))?;
unsigned.signature = sig;
let bytes = serialize::serialize(&Object::Commit(unsigned))
.map_err(|e| emit_err(&format!("serialize: {e}"), exit::DATAERR))?;
store
.write(&bytes)
.map_err(|e| emit_err(&format!("store: {e}"), exit::CANTCREAT))
}
fn load_tree_hash(store: &ObjectStore, commit_hash: Hash) -> Result<Hash, u8> {
match store.read_object(&commit_hash) {
Ok(Object::Commit(c)) => Ok(c.tree_hash),
Ok(_) => Err(emit_err("object is not a commit", exit::DATAERR)),
Err(e) => Err(emit_err(&format!("read commit: {e}"), exit::GENERAL_ERROR)),
}
}
struct StepCommit {
parent: Hash,
message: Vec<u8>,
author: Identity,
timestamp: u64,
}
fn plan_step_commit(
store: &ObjectStore,
action: RebaseAction,
target: Hash,
head_hash: Hash,
) -> Result<StepCommit, u8> {
match action {
RebaseAction::Pick => {
let original = read_commit(store, target)?;
Ok(StepCommit {
parent: head_hash,
message: original.message,
author: original.author,
timestamp: original.timestamp,
})
}
RebaseAction::Reword => {
let original = read_commit(store, target)?;
Ok(StepCommit {
parent: head_hash,
message: reworded_message(&original.message)?,
author: original.author,
timestamp: original.timestamp,
})
}
RebaseAction::Squash | RebaseAction::Fixup => {
let head_commit = read_commit(store, head_hash)?;
let parent = head_commit.parents.first().copied().ok_or_else(|| {
emit_err(
"'squash'/'fixup' has no preceding commit to fold into",
exit::DATAERR,
)
})?;
let message = if action == RebaseAction::Fixup {
head_commit.message.clone()
} else {
let target_msg = read_commit(store, target)?.message;
squashed_message(&head_commit.message, &target_msg)?
};
Ok(StepCommit {
parent,
message,
author: head_commit.author,
timestamp: head_commit.timestamp,
})
}
}
}
fn read_commit(store: &ObjectStore, h: Hash) -> Result<Commit, u8> {
match store.read_object(&h) {
Ok(Object::Commit(c)) => Ok(c),
Ok(_) => Err(emit_err("object is not a commit", exit::DATAERR)),
Err(e) => Err(emit_err(&format!("read commit: {e}"), exit::GENERAL_ERROR)),
}
}
fn reworded_message(original: &[u8]) -> Result<Vec<u8>, u8> {
let seed = reword_template(original);
match editor::spawn_editor(&seed) {
Ok(s) if !s.trim().is_empty() => Ok(s.into_bytes()),
Ok(_) => {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "reword: empty message; keeping the original");
Ok(original.to_vec())
}
Err(e) => Err(emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR)),
}
}
fn squashed_message(head_msg: &[u8], target_msg: &[u8]) -> Result<Vec<u8>, u8> {
let seed = format!(
"{}\n\n{}\n\n\
# This is a combination of 2 commits; the first message is the one\n\
# being squashed into. Edit the combined message above. Lines\n\
# starting with '#' are ignored.\n",
String::from_utf8_lossy(head_msg),
String::from_utf8_lossy(target_msg),
);
match editor::spawn_editor(&seed) {
Ok(s) if !s.trim().is_empty() => Ok(s.into_bytes()),
Ok(_) => {
let mut combined = head_msg.to_vec();
combined.extend_from_slice(b"\n\n");
combined.extend_from_slice(target_msg);
Ok(combined)
}
Err(e) => Err(emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR)),
}
}
fn reword_template(original: &[u8]) -> String {
format!(
"{}\n\
# Reword: edit the commit message above. Lines starting with '#'\n\
# are ignored. An empty message keeps the original message.\n",
String::from_utf8_lossy(original)
)
}
fn commit_subject(store: &ObjectStore, h: Hash) -> String {
match store.read_object(&h) {
Ok(Object::Commit(c)) => {
let text = String::from_utf8_lossy(&c.message);
text.lines().next().unwrap_or("").trim().to_string()
}
_ => String::new(),
}
}
#[allow(clippy::type_complexity)]
fn edit_todo(
store: &ObjectStore,
candidates: &[Hash],
orig_head: Hash,
onto: Hash,
) -> Result<(Vec<Hash>, Vec<RebaseAction>), u8> {
use std::fmt::Write as _;
let mut template = String::new();
for h in candidates {
let _ = writeln!(
template,
"pick {} {}",
format::short_hash(h, 12),
commit_subject(store, *h)
);
}
let _ = write!(
template,
"\n\
# Rebase {}..{} onto {}.\n\
#\n\
# Commands (one per line, in apply order — top is applied first):\n\
# p, pick <commit> = use the commit\n\
# r, reword <commit> = use the commit, but edit its message\n\
# s, squash <commit> = fold into the previous commit, combining messages\n\
# f, fixup <commit> = fold into the previous commit, discard this message\n\
# d, drop <commit> = remove the commit\n\
#\n\
# Reorder lines to reorder commits. Deleting a line drops that commit.\n\
# A squash/fixup cannot be the first line. 'edit' is not yet supported.\n\
# Removing every line resets the branch to the base.\n",
format::short_hash(&onto, 12),
format::short_hash(&orig_head, 12),
format::short_hash(&onto, 12),
);
let edited = editor::spawn_editor(&template).map_err(|e| {
emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR)
})?;
parse_todo(candidates, &edited)
}
#[allow(clippy::type_complexity)]
fn parse_todo(candidates: &[Hash], edited: &str) -> Result<(Vec<Hash>, Vec<RebaseAction>), u8> {
let mut todo = Vec::new();
let mut actions = Vec::new();
for raw in edited.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split_whitespace();
let verb = parts.next().unwrap_or("");
let action = match verb {
"p" | "pick" => RebaseAction::Pick,
"r" | "reword" => RebaseAction::Reword,
"s" | "squash" => RebaseAction::Squash,
"f" | "fixup" => RebaseAction::Fixup,
"d" | "drop" => {
let _ = resolve_todo_hash(candidates, parts.next(), line)?;
continue;
}
"e" | "edit" => {
return Err(emit_err(
"'edit' (stop to amend) is not yet supported; use pick, reword, squash, fixup, or drop",
exit::USAGE,
));
}
other => {
return Err(emit_err(
&format!("unknown rebase command '{other}'"),
exit::USAGE,
));
}
};
if todo.is_empty() && action.folds_into_previous() {
return Err(emit_err(
&format!("cannot '{verb}' as the first commit; it has nothing to fold into"),
exit::USAGE,
));
}
let h = resolve_todo_hash(candidates, parts.next(), line)?;
todo.push(h);
actions.push(action);
}
Ok((todo, actions))
}
fn resolve_todo_hash(candidates: &[Hash], token: Option<&str>, line: &str) -> Result<Hash, u8> {
let token = token.ok_or_else(|| {
emit_err(
&format!("missing commit on todo line: '{line}'"),
exit::USAGE,
)
})?;
let token = token.to_ascii_lowercase();
let matches: Vec<&Hash> = candidates
.iter()
.filter(|h| mkit_core::hash::to_hex(h).starts_with(&token))
.collect();
match matches.as_slice() {
[h] => Ok(**h),
[] => Err(emit_err(
&format!("todo line refers to an unknown commit: '{line}'"),
exit::USAGE,
)),
_ => Err(emit_err(
&format!("ambiguous commit '{token}' on todo line: '{line}'"),
exit::USAGE,
)),
}
}
use super::error as emit_err;