1use 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#[allow(clippy::struct_excessive_bools)]
66struct RebaseOpts {
67 #[arg(long = "continue", conflicts_with_all = ["abort", "skip", "branch"])]
69 cont: bool,
70 #[arg(long, conflicts_with_all = ["cont", "skip", "branch"])]
72 abort: bool,
73 #[arg(long, conflicts_with_all = ["cont", "abort", "branch"])]
75 skip: bool,
76 #[arg(short = 'i', long, conflicts_with_all = ["cont", "abort", "skip"])]
80 interactive: bool,
81 #[arg(long, value_enum, default_value = "default")]
87 format: RebaseFormat,
88 branch: Option<String>,
91}
92
93fn 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 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 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 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 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
253fn 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 replay(layout, store, None, json)
283}
284
285fn 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 let op_result = conflict_state::read_result_tree(rebase_dir).ok().flatten();
307 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
323fn 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 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 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
392fn 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 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 let op_result = conflict_state::read_result_tree(&rebase_dir).ok().flatten();
436 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 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 conflict_state::clear_result_tree(&rebase_dir);
527 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 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 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 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 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 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 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 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
769struct StepCommit {
777 parent: Hash,
778 message: Vec<u8>,
779 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 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
849fn 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
864fn 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
887fn 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
898fn 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#[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 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 emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR)
958 })?;
959
960 parse_todo(candidates, &edited)
961}
962
963#[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 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 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
1019fn 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;