1use sley_core::ObjectId;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TodoCommand {
18 Pick,
19 Revert,
20 Edit,
21 Reword,
22 Fixup,
23 Squash,
24 Exec,
25 Break,
26 Label,
27 Reset,
28 Merge,
29 UpdateRef,
30 Noop,
31 Drop,
32 Comment,
33}
34
35pub const FLAG_EDIT_MERGE_MSG: u8 = 1 << 0;
37pub const FLAG_REPLACE_FIXUP_MSG: u8 = 1 << 1;
39pub const FLAG_EDIT_FIXUP_MSG: u8 = 1 << 2;
41
42impl TodoCommand {
43 const ORDER: [TodoCommand; 14] = [
44 TodoCommand::Pick,
45 TodoCommand::Revert,
46 TodoCommand::Edit,
47 TodoCommand::Reword,
48 TodoCommand::Fixup,
49 TodoCommand::Squash,
50 TodoCommand::Exec,
51 TodoCommand::Break,
52 TodoCommand::Label,
53 TodoCommand::Reset,
54 TodoCommand::Merge,
55 TodoCommand::UpdateRef,
56 TodoCommand::Noop,
57 TodoCommand::Drop,
58 ];
59
60 pub fn as_str(self) -> &'static str {
61 match self {
62 TodoCommand::Pick => "pick",
63 TodoCommand::Revert => "revert",
64 TodoCommand::Edit => "edit",
65 TodoCommand::Reword => "reword",
66 TodoCommand::Fixup => "fixup",
67 TodoCommand::Squash => "squash",
68 TodoCommand::Exec => "exec",
69 TodoCommand::Break => "break",
70 TodoCommand::Label => "label",
71 TodoCommand::Reset => "reset",
72 TodoCommand::Merge => "merge",
73 TodoCommand::UpdateRef => "update-ref",
74 TodoCommand::Noop => "noop",
75 TodoCommand::Drop => "drop",
76 TodoCommand::Comment => "comment",
77 }
78 }
79
80 fn nick(self) -> Option<char> {
81 match self {
82 TodoCommand::Pick => Some('p'),
83 TodoCommand::Edit => Some('e'),
84 TodoCommand::Reword => Some('r'),
85 TodoCommand::Fixup => Some('f'),
86 TodoCommand::Squash => Some('s'),
87 TodoCommand::Exec => Some('x'),
88 TodoCommand::Break => Some('b'),
89 TodoCommand::Label => Some('l'),
90 TodoCommand::Reset => Some('t'),
91 TodoCommand::Merge => Some('m'),
92 TodoCommand::UpdateRef => Some('u'),
93 TodoCommand::Drop => Some('d'),
94 TodoCommand::Revert | TodoCommand::Noop | TodoCommand::Comment => None,
95 }
96 }
97
98 pub fn is_noop(self) -> bool {
100 matches!(
101 self,
102 TodoCommand::Noop | TodoCommand::Drop | TodoCommand::Comment
103 )
104 }
105
106 pub fn is_fixup(self) -> bool {
107 matches!(self, TodoCommand::Fixup | TodoCommand::Squash)
108 }
109
110 pub fn is_pick_or_similar(self) -> bool {
112 matches!(
113 self,
114 TodoCommand::Pick
115 | TodoCommand::Revert
116 | TodoCommand::Edit
117 | TodoCommand::Reword
118 | TodoCommand::Fixup
119 | TodoCommand::Squash
120 )
121 }
122}
123
124#[derive(Debug, Clone)]
127pub struct RebaseTodoItem {
128 pub command: TodoCommand,
129 pub flags: u8,
130 pub oid: Option<ObjectId>,
132 pub arg: String,
136 pub raw: String,
137}
138
139impl RebaseTodoItem {
140 pub fn comment(line: &str) -> Self {
141 RebaseTodoItem {
142 command: TodoCommand::Comment,
143 flags: 0,
144 oid: None,
145 arg: line.to_string(),
146 raw: line.to_string(),
147 }
148 }
149}
150
151pub enum TodoOidLookup {
153 Commit { oid: ObjectId, parents: usize },
155 Missing,
157}
158
159pub type TodoParseMessages = Vec<String>;
162
163fn strip_todo_command(bol: &str, command: TodoCommand) -> Option<&str> {
166 let word = command.as_str();
167 let separator_ok = |rest: &str| rest.is_empty() || rest.starts_with([' ', '\t', '\n', '\r']);
168 if let Some(rest) = bol.strip_prefix(word)
169 && separator_ok(rest)
170 {
171 return Some(rest);
172 }
173 if let Some(nick) = command.nick() {
174 let mut chars = bol.chars();
175 if chars.next() == Some(nick) {
176 let rest = chars.as_str();
177 if separator_ok(rest) {
178 return Some(rest);
179 }
180 }
181 }
182 None
183}
184
185pub fn parse_todo_buffer(
191 text: &str,
192 done_exists: bool,
193 comment_char: char,
194 resolve: &mut dyn FnMut(&str) -> TodoOidLookup,
195) -> (Vec<RebaseTodoItem>, TodoParseMessages) {
196 let mut items = Vec::new();
197 let mut messages = Vec::new();
198 let mut fixup_okay = done_exists;
199 let mut line_number = 0usize;
200 for raw_line in text.split('\n') {
201 line_number += 1;
202 if raw_line.is_empty() && text.split('\n').count() == line_number {
205 break;
206 }
207 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
208 match parse_todo_line(line, comment_char, resolve, &mut messages) {
209 Ok(item) => {
210 if !fixup_okay && item.command.is_fixup() {
211 messages.push(format!(
212 "error: cannot '{}' without a previous commit",
213 item.command.as_str()
214 ));
215 } else if !item.command.is_noop() {
216 fixup_okay = true;
217 }
218 items.push(item);
219 }
220 Err(()) => {
221 messages.push(format!("error: invalid line {line_number}: {line}"));
222 items.push(RebaseTodoItem::comment(line));
223 }
224 }
225 }
226 (items, messages)
227}
228
229fn parse_todo_line(
230 line: &str,
231 comment_char: char,
232 resolve: &mut dyn FnMut(&str) -> TodoOidLookup,
233 messages: &mut TodoParseMessages,
234) -> std::result::Result<RebaseTodoItem, ()> {
235 let bol = line.trim_start_matches([' ', '\t']);
236 if bol.is_empty() || bol.starts_with(comment_char) {
237 return Ok(RebaseTodoItem::comment(line));
238 }
239 let mut matched = None;
240 for command in TodoCommand::ORDER {
241 if let Some(rest) = strip_todo_command(bol, command) {
242 matched = Some((command, rest));
243 break;
244 }
245 }
246 let Some((command, rest)) = matched else {
247 let token: String = bol
248 .chars()
249 .take_while(|c| !matches!(c, ' ' | '\t' | '\r' | '\n'))
250 .collect();
251 messages.push(format!("error: invalid command '{token}'"));
252 return Err(());
253 };
254
255 let padding = rest.len() - rest.trim_start_matches([' ', '\t']).len();
256 let mut bol = rest.trim_start_matches([' ', '\t']);
257
258 if matches!(command, TodoCommand::Noop | TodoCommand::Break) {
259 if !bol.is_empty() {
260 messages.push(format!(
261 "error: {} does not accept arguments: '{bol}'",
262 command.as_str()
263 ));
264 return Err(());
265 }
266 return Ok(RebaseTodoItem {
267 command,
268 flags: 0,
269 oid: None,
270 arg: String::new(),
271 raw: line.to_string(),
272 });
273 }
274
275 if padding == 0 {
276 messages.push(format!("error: missing arguments for {}", command.as_str()));
277 return Err(());
278 }
279
280 if matches!(
281 command,
282 TodoCommand::Exec | TodoCommand::Label | TodoCommand::Reset | TodoCommand::UpdateRef
283 ) {
284 return Ok(RebaseTodoItem {
285 command,
286 flags: 0,
287 oid: None,
288 arg: bol.to_string(),
289 raw: line.to_string(),
290 });
291 }
292
293 let mut flags = 0u8;
294 if command == TodoCommand::Fixup {
295 if let Some(rest) = bol.strip_prefix("-C") {
296 bol = rest.trim_start_matches([' ', '\t']);
297 flags |= FLAG_REPLACE_FIXUP_MSG;
298 } else if let Some(rest) = bol.strip_prefix("-c") {
299 bol = rest.trim_start_matches([' ', '\t']);
300 flags |= FLAG_EDIT_FIXUP_MSG;
301 }
302 }
303 if command == TodoCommand::Merge {
304 if let Some(rest) = bol.strip_prefix("-C") {
305 bol = rest.trim_start_matches([' ', '\t']);
306 } else if let Some(rest) = bol.strip_prefix("-c") {
307 bol = rest.trim_start_matches([' ', '\t']);
308 flags |= FLAG_EDIT_MERGE_MSG;
309 } else {
310 return Ok(RebaseTodoItem {
311 command,
312 flags: FLAG_EDIT_MERGE_MSG,
313 oid: None,
314 arg: bol.to_string(),
315 raw: line.to_string(),
316 });
317 }
318 }
319
320 let end = bol.find([' ', '\t', '\n']).unwrap_or(bol.len());
321 let (object_name, tail) = bol.split_at(end);
322 let arg = tail.trim_start_matches([' ', '\t']).to_string();
323 match resolve(object_name) {
324 TodoOidLookup::Commit { oid, parents } => {
325 if parents > 1 {
326 push_merge_commit_messages(command, messages);
327 return Err(());
328 }
329 Ok(RebaseTodoItem {
330 command,
331 flags,
332 oid: Some(oid),
333 arg,
334 raw: line.to_string(),
335 })
336 }
337 TodoOidLookup::Missing => {
338 messages.push(format!("error: could not parse '{object_name}'"));
339 Err(())
340 }
341 }
342}
343
344fn push_merge_commit_messages(command: TodoCommand, messages: &mut TodoParseMessages) {
347 match command {
348 TodoCommand::Pick => {
349 messages.push("error: 'pick' does not accept merge commits".to_string());
350 for line in [
351 "'pick' does not take a merge commit. If you wanted to",
352 "replay the merge, use 'merge -C' on the commit.",
353 ] {
354 messages.push(format!("hint: {line}"));
355 }
356 push_todo_error_disable_hint(messages);
357 }
358 TodoCommand::Reword => {
359 messages.push("error: 'reword' does not accept merge commits".to_string());
360 for line in [
361 "'reword' does not take a merge commit. If you wanted to",
362 "replay the merge and reword the commit message, use",
363 "'merge -c' on the commit",
364 ] {
365 messages.push(format!("hint: {line}"));
366 }
367 push_todo_error_disable_hint(messages);
368 }
369 TodoCommand::Edit => {
370 messages.push("error: 'edit' does not accept merge commits".to_string());
371 for line in [
372 "'edit' does not take a merge commit. If you wanted to",
373 "replay the merge, use 'merge -C' on the commit, and then",
374 "'break' to give the control back to you so that you can",
375 "do 'git commit --amend && git rebase --continue'.",
376 ] {
377 messages.push(format!("hint: {line}"));
378 }
379 push_todo_error_disable_hint(messages);
380 }
381 TodoCommand::Fixup | TodoCommand::Squash => {
382 messages.push("error: cannot squash merge commit into another commit".to_string());
383 }
384 _ => {}
385 }
386}
387
388fn push_todo_error_disable_hint(messages: &mut TodoParseMessages) {
389 messages.push(
390 "hint: Disable this message with \"git config set advice.rebaseTodoError false\""
391 .to_string(),
392 );
393}
394
395pub fn todo_item_to_string(item: &RebaseTodoItem, oid_text: Option<&str>) -> String {
398 if item.command == TodoCommand::Comment {
399 return item.arg.clone();
400 }
401 let mut out = String::from(item.command.as_str());
402 if let Some(oid) = oid_text {
403 if item.command == TodoCommand::Fixup {
404 if item.flags & FLAG_EDIT_FIXUP_MSG != 0 {
405 out.push_str(" -c");
406 } else if item.flags & FLAG_REPLACE_FIXUP_MSG != 0 {
407 out.push_str(" -C");
408 }
409 }
410 if item.command == TodoCommand::Merge {
411 if item.flags & FLAG_EDIT_MERGE_MSG != 0 {
412 out.push_str(" -c");
413 } else {
414 out.push_str(" -C");
415 }
416 }
417 out.push(' ');
418 out.push_str(oid);
419 }
420 if !item.arg.is_empty() {
421 out.push(' ');
422 out.push_str(&item.arg);
423 }
424 out
425}
426
427const TODO_HELP_COMMANDS: &str = "\
429\nCommands:
430p, pick <commit> = use commit
431r, reword <commit> = use commit, but edit the commit message
432e, edit <commit> = use commit, but stop for amending
433s, squash <commit> = use commit, but meld into previous commit
434f, fixup [-C | -c] <commit> = like \"squash\" but keep only the previous
435 commit's log message, unless -C is used, in which case
436 keep only this commit's message; -c is same as -C but
437 opens the editor
438x, exec <command> = run command (the rest of the line) using shell
439b, break = stop here (continue rebase later with 'git rebase --continue')
440d, drop <commit> = remove commit
441l, label <label> = label current HEAD with a name
442t, reset <label> = reset HEAD to a label
443m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
444 create a merge commit using the original merge commit's
445 message (or the oneline, if no original merge commit was
446 specified); use -c <commit> to reword the commit message
447u, update-ref <ref> = track a placeholder for the <ref> to be updated
448 to this position in the new commits. The <ref> is
449 updated at the end of the rebase
450
451These lines can be re-ordered; they are executed from top to bottom.
452";
453
454fn add_commented_lines(buf: &mut String, text: &str, comment: char) {
455 for line in text.split_inclusive('\n') {
456 let body = line.strip_suffix('\n');
457 let content = body.unwrap_or(line);
458 if content.is_empty() {
459 buf.push(comment);
460 } else {
461 buf.push(comment);
462 buf.push(' ');
463 buf.push_str(content);
464 }
465 buf.push('\n');
466 }
467}
468
469pub fn append_todo_help(
474 buf: &mut String,
475 command_count: usize,
476 shortrevisions: Option<&str>,
477 shortonto: Option<&str>,
478 comment: char,
479 check_level_error: bool,
480) {
481 let edit_todo = !(shortrevisions.is_some() && shortonto.is_some());
482 if !edit_todo {
483 buf.push('\n');
484 let plural = if command_count == 1 {
485 "command"
486 } else {
487 "commands"
488 };
489 buf.push(comment);
490 buf.push(' ');
491 buf.push_str(&format!(
492 "Rebase {} onto {} ({command_count} {plural})\n",
493 shortrevisions.unwrap_or_default(),
494 shortonto.unwrap_or_default()
495 ));
496 }
497 add_commented_lines(buf, TODO_HELP_COMMANDS, comment);
498 let msg = if check_level_error {
499 "\nDo not remove any line. Use 'drop' explicitly to remove a commit.\n"
500 } else {
501 "\nIf you remove a line here THAT COMMIT WILL BE LOST.\n"
502 };
503 add_commented_lines(buf, msg, comment);
504 let msg = if edit_todo {
505 "\nYou are editing the todo file of an ongoing interactive rebase.\nTo continue rebase after editing, run:\n git rebase --continue\n\n"
506 } else {
507 "\nHowever, if you remove everything, the rebase will be aborted.\n\n"
508 };
509 add_commented_lines(buf, msg, comment);
510}
511
512pub fn merge_dir(git_dir: &Path) -> PathBuf {
517 git_dir.join("rebase-merge")
518}
519
520pub fn state_path(git_dir: &Path, name: &str) -> PathBuf {
521 merge_dir(git_dir).join(name)
522}
523
524pub fn in_progress(git_dir: &Path) -> bool {
525 merge_dir(git_dir).is_dir()
526}
527
528pub fn read_state_line(git_dir: &Path, name: &str) -> Option<String> {
530 let text = fs::read_to_string(state_path(git_dir, name)).ok()?;
531 Some(text.trim_end_matches('\n').to_string())
532}
533
534pub fn write_state_file(git_dir: &Path, name: &str, contents: &str) -> std::io::Result<()> {
535 fs::write(state_path(git_dir, name), contents)
536}
537
538pub fn remove_merge_state(git_dir: &Path) {
539 let _ = fs::remove_dir_all(merge_dir(git_dir));
540}
541
542fn sq_quote(value: &str) -> String {
545 let mut out = String::with_capacity(value.len() + 2);
546 out.push('\'');
547 for c in value.chars() {
548 if c == '\'' || c == '!' {
549 out.push('\'');
550 out.push('\\');
551 out.push(c);
552 out.push('\'');
553 } else {
554 out.push(c);
555 }
556 }
557 out.push('\'');
558 out
559}
560
561pub fn format_author_script(author: &[u8]) -> Option<String> {
564 let text = String::from_utf8_lossy(author);
565 let open = text.find('<')?;
566 let close = text[open..].find('>')? + open;
567 let name = text[..open].trim_end();
568 let email = &text[open + 1..close];
569 let date = text[close + 1..].trim();
570 Some(format!(
571 "GIT_AUTHOR_NAME={}\nGIT_AUTHOR_EMAIL={}\nGIT_AUTHOR_DATE={}\n",
572 sq_quote(name),
573 sq_quote(email),
574 sq_quote(&format!("@{date}"))
575 ))
576}
577
578pub fn parse_author_script(text: &str) -> Option<(String, String, String)> {
581 let mut name = None;
582 let mut email = None;
583 let mut date = None;
584 for line in text.lines() {
585 let (key, value) = line.split_once('=')?;
586 let value = sq_dequote(value)?;
587 match key {
588 "GIT_AUTHOR_NAME" => name = Some(value),
589 "GIT_AUTHOR_EMAIL" => email = Some(value),
590 "GIT_AUTHOR_DATE" => date = Some(value),
591 _ => return None,
592 }
593 }
594 Some((name?, email?, date?))
595}
596
597fn sq_dequote(value: &str) -> Option<String> {
598 let mut out = String::new();
599 let mut chars = value.chars().peekable();
600 if chars.next()? != '\'' {
601 return None;
602 }
603 loop {
604 let c = chars.next()?;
605 if c == '\'' {
606 match chars.peek() {
607 None => return Some(out),
608 Some('\\') => {
609 chars.next();
610 let escaped = chars.next()?;
611 out.push(escaped);
612 if chars.next()? != '\'' {
613 return None;
614 }
615 }
616 Some(_) => return None,
617 }
618 } else {
619 out.push(c);
620 }
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627 use sley_core::ObjectFormat;
628
629 fn oid(hex: &str) -> ObjectId {
630 ObjectId::from_hex(ObjectFormat::Sha1, hex).expect("test operation should succeed")
631 }
632
633 fn resolver(token: &str) -> TodoOidLookup {
634 if token.len() >= 7 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
635 TodoOidLookup::Commit {
636 oid: oid("21b83cd2e8f4d6d8d9615779ebaa801ba891eb04"),
637 parents: 1,
638 }
639 } else {
640 TodoOidLookup::Missing
641 }
642 }
643
644 #[test]
645 fn parses_commands_and_nicks() {
646 let text = "pick 21b83cd # one\nr 21b83cd # two\nbreak\nexec make test\n# comment\n\ndrop 21b83cd # three\n";
647 let (items, messages) = parse_todo_buffer(text, false, '#', &mut resolver);
648 assert!(messages.is_empty(), "{messages:?}");
649 let commands: Vec<TodoCommand> = items.iter().map(|item| item.command).collect();
650 assert_eq!(
651 commands,
652 vec![
653 TodoCommand::Pick,
654 TodoCommand::Reword,
655 TodoCommand::Break,
656 TodoCommand::Exec,
657 TodoCommand::Comment,
658 TodoCommand::Comment,
659 TodoCommand::Drop,
660 ]
661 );
662 assert_eq!(items[0].arg, "# one");
663 assert_eq!(items[3].arg, "make test");
664 assert_eq!(items[0].raw, "pick 21b83cd # one");
665 }
666
667 #[test]
668 fn flags_bad_lines_in_order() {
669 let (_, messages) = parse_todo_buffer("pickled 21b83cd # x\n", false, '#', &mut resolver);
670 assert_eq!(
671 messages,
672 vec![
673 "error: invalid command 'pickled'".to_string(),
674 "error: invalid line 1: pickled 21b83cd # x".to_string(),
675 ]
676 );
677 let (_, messages) = parse_todo_buffer("pick nope # x\n", false, '#', &mut resolver);
678 assert_eq!(
679 messages,
680 vec![
681 "error: could not parse 'nope'".to_string(),
682 "error: invalid line 1: pick nope # x".to_string(),
683 ]
684 );
685 let (_, messages) = parse_todo_buffer("fixup 21b83cd # x\n", false, '#', &mut resolver);
686 assert_eq!(
687 messages,
688 vec!["error: cannot 'fixup' without a previous commit".to_string()]
689 );
690 }
691
692 #[test]
693 fn fixup_flags_parse() {
694 let (items, messages) = parse_todo_buffer(
695 "pick 21b83cd # a\nfixup -C 21b83cd # b\nfixup -c 21b83cd # c\n",
696 false,
697 '#',
698 &mut resolver,
699 );
700 assert!(messages.is_empty());
701 assert_eq!(items[1].flags, FLAG_REPLACE_FIXUP_MSG);
702 assert_eq!(items[2].flags, FLAG_EDIT_FIXUP_MSG);
703 assert_eq!(
704 todo_item_to_string(&items[1], Some("21b83cd")),
705 "fixup -C 21b83cd # b"
706 );
707 }
708
709 #[test]
710 fn todo_help_initial_variant() {
711 let mut buf = String::new();
712 append_todo_help(&mut buf, 2, Some("123..456"), Some("123"), '#', false);
713 assert!(buf.starts_with("\n# Rebase 123..456 onto 123 (2 commands)\n"));
714 assert!(buf.contains("# p, pick <commit> = use commit\n"));
715 assert!(buf.contains("# However, if you remove everything, the rebase will be aborted.\n"));
716 assert!(buf.ends_with("#\n"));
717 }
718
719 #[test]
720 fn author_script_round_trips() {
721 let script = format_author_script(b"A U Thor <a@example.com> 1234567890 +0100")
722 .expect("test operation should succeed");
723 assert_eq!(
724 script,
725 "GIT_AUTHOR_NAME='A U Thor'\nGIT_AUTHOR_EMAIL='a@example.com'\nGIT_AUTHOR_DATE='@1234567890 +0100'\n"
726 );
727 let (name, email, date) =
728 parse_author_script(&script).expect("test operation should succeed");
729 assert_eq!(name, "A U Thor");
730 assert_eq!(email, "a@example.com");
731 assert_eq!(date, "@1234567890 +0100");
732 }
733}