1use 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#[allow(clippy::struct_excessive_bools)]
59struct RebaseOpts {
60 #[arg(long = "continue", conflicts_with_all = ["abort", "skip", "branch"])]
62 cont: bool,
63 #[arg(long, conflicts_with_all = ["cont", "skip", "branch"])]
65 abort: bool,
66 #[arg(long, conflicts_with_all = ["cont", "abort", "branch"])]
68 skip: bool,
69 #[arg(short = 'i', long, conflicts_with_all = ["cont", "abort", "skip"])]
73 interactive: bool,
74 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 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 let (todo, actions) = if interactive {
159 if candidates.is_empty() {
160 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 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
213fn 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 replay(cwd, mkit_dir, store, None)
250}
251
252fn 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
280fn 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 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
346fn 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 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 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 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 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 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 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 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
644struct StepCommit {
652 parent: Hash,
653 message: Vec<u8>,
654 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 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
724fn 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
739fn 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
762fn 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
773fn 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#[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 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 emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR)
833 })?;
834
835 parse_todo(candidates, &edited)
836}
837
838#[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 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 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
894fn 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}