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 pub 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 command == TodoCommand::Label {
281 if !valid_label(bol) {
282 messages.push(format!("error: '{}' is not a valid label", bol));
283 return Err(());
284 }
285 return Ok(RebaseTodoItem {
286 command,
287 flags: 0,
288 oid: None,
289 arg: bol.to_string(),
290 raw: line.to_string(),
291 });
292 }
293
294 if command == TodoCommand::UpdateRef {
295 if !bol.starts_with("refs/") {
296 if !valid_refname(bol, true) {
297 messages.push(format!("error: '{}' is not a valid refname", bol));
298 } else {
299 messages.push(
300 "error: update-ref requires a fully qualified refname e.g. refs/heads/topic"
301 .to_string(),
302 );
303 }
304 return Err(());
305 }
306 if !valid_refname(bol, false) {
307 messages.push(format!("error: '{}' is not a valid refname", bol));
308 return Err(());
309 }
310 return Ok(RebaseTodoItem {
311 command,
312 flags: 0,
313 oid: None,
314 arg: bol.to_string(),
315 raw: line.to_string(),
316 });
317 }
318
319 if matches!(command, TodoCommand::Exec | TodoCommand::Reset) {
320 return Ok(RebaseTodoItem {
321 command,
322 flags: 0,
323 oid: None,
324 arg: bol.to_string(),
325 raw: line.to_string(),
326 });
327 }
328
329 let mut flags = 0u8;
330 if command == TodoCommand::Fixup {
331 if let Some(rest) = bol.strip_prefix("-C") {
332 bol = rest.trim_start_matches([' ', '\t']);
333 flags |= FLAG_REPLACE_FIXUP_MSG;
334 } else if let Some(rest) = bol.strip_prefix("-c") {
335 bol = rest.trim_start_matches([' ', '\t']);
336 flags |= FLAG_EDIT_FIXUP_MSG;
337 }
338 }
339 if command == TodoCommand::Merge {
340 if let Some(rest) = bol.strip_prefix("-C") {
341 bol = rest.trim_start_matches([' ', '\t']);
342 } else if let Some(rest) = bol.strip_prefix("-c") {
343 bol = rest.trim_start_matches([' ', '\t']);
344 flags |= FLAG_EDIT_MERGE_MSG;
345 } else {
346 return Ok(RebaseTodoItem {
347 command,
348 flags: FLAG_EDIT_MERGE_MSG,
349 oid: None,
350 arg: bol.to_string(),
351 raw: line.to_string(),
352 });
353 }
354 }
355
356 let end = bol.find([' ', '\t', '\n']).unwrap_or(bol.len());
357 let (object_name, tail) = bol.split_at(end);
358 let arg = tail.trim_start_matches([' ', '\t']).to_string();
359 match resolve(object_name) {
360 TodoOidLookup::Commit { oid, parents } => {
361 if parents > 1 && !matches!(command, TodoCommand::Merge | TodoCommand::Drop) {
362 push_merge_commit_messages(command, messages);
363 return Err(());
364 }
365 Ok(RebaseTodoItem {
366 command,
367 flags,
368 oid: Some(oid),
369 arg,
370 raw: line.to_string(),
371 })
372 }
373 TodoOidLookup::Missing => {
374 messages.push(format!("error: could not parse '{object_name}'"));
375 Err(())
376 }
377 }
378}
379
380fn valid_label(label: &str) -> bool {
381 !label.is_empty()
382 && label != "#"
383 && !label.starts_with(':')
384 && !label.contains('/')
385 && !label.contains("..")
386 && !label.contains("@{")
387 && !label.ends_with('.')
388 && !label.ends_with(".lock")
389 && label
390 .bytes()
391 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
392}
393
394fn valid_refname(refname: &str, allow_onelevel: bool) -> bool {
395 if refname.is_empty()
396 || refname.starts_with('/')
397 || refname.ends_with('/')
398 || refname.contains("..")
399 || refname.contains("@{")
400 || refname.ends_with('.')
401 || refname.ends_with(".lock")
402 {
403 return false;
404 }
405 let mut components = 0usize;
406 for component in refname.split('/') {
407 components += 1;
408 if component.is_empty()
409 || component.starts_with('.')
410 || component.ends_with(".lock")
411 || component.bytes().any(|b| {
412 b < 0x20
413 || b == 0x7f
414 || matches!(b, b' ' | b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
415 })
416 {
417 return false;
418 }
419 }
420 allow_onelevel || components >= 2
421}
422
423fn push_merge_commit_messages(command: TodoCommand, messages: &mut TodoParseMessages) {
426 match command {
427 TodoCommand::Pick => {
428 messages.push("error: 'pick' does not accept merge commits".to_string());
429 for line in [
430 "'pick' does not take a merge commit. If you wanted to",
431 "replay the merge, use 'merge -C' on the commit.",
432 ] {
433 messages.push(format!("hint: {line}"));
434 }
435 push_todo_error_disable_hint(messages);
436 }
437 TodoCommand::Reword => {
438 messages.push("error: 'reword' does not accept merge commits".to_string());
439 for line in [
440 "'reword' does not take a merge commit. If you wanted to",
441 "replay the merge and reword the commit message, use",
442 "'merge -c' on the commit",
443 ] {
444 messages.push(format!("hint: {line}"));
445 }
446 push_todo_error_disable_hint(messages);
447 }
448 TodoCommand::Edit => {
449 messages.push("error: 'edit' does not accept merge commits".to_string());
450 for line in [
451 "'edit' does not take a merge commit. If you wanted to",
452 "replay the merge, use 'merge -C' on the commit, and then",
453 "'break' to give the control back to you so that you can",
454 "do 'git commit --amend && git rebase --continue'.",
455 ] {
456 messages.push(format!("hint: {line}"));
457 }
458 push_todo_error_disable_hint(messages);
459 }
460 TodoCommand::Fixup | TodoCommand::Squash => {
461 messages.push("error: cannot squash merge commit into another commit".to_string());
462 }
463 _ => {}
464 }
465}
466
467fn push_todo_error_disable_hint(messages: &mut TodoParseMessages) {
468 messages.push(
469 "hint: Disable this message with \"git config set advice.rebaseTodoError false\""
470 .to_string(),
471 );
472}
473
474pub fn todo_item_to_string(item: &RebaseTodoItem, oid_text: Option<&str>) -> String {
477 if item.command == TodoCommand::Comment {
478 return item.arg.clone();
479 }
480 let mut out = String::from(item.command.as_str());
481 if let Some(oid) = oid_text {
482 if item.command == TodoCommand::Fixup {
483 if item.flags & FLAG_EDIT_FIXUP_MSG != 0 {
484 out.push_str(" -c");
485 } else if item.flags & FLAG_REPLACE_FIXUP_MSG != 0 {
486 out.push_str(" -C");
487 }
488 }
489 if item.command == TodoCommand::Merge {
490 if item.flags & FLAG_EDIT_MERGE_MSG != 0 {
491 out.push_str(" -c");
492 } else {
493 out.push_str(" -C");
494 }
495 }
496 out.push(' ');
497 out.push_str(oid);
498 }
499 if !item.arg.is_empty() {
500 out.push(' ');
501 out.push_str(&item.arg);
502 }
503 out
504}
505
506const TODO_HELP_COMMANDS: &str = "\
508\nCommands:
509p, pick <commit> = use commit
510r, reword <commit> = use commit, but edit the commit message
511e, edit <commit> = use commit, but stop for amending
512s, squash <commit> = use commit, but meld into previous commit
513f, fixup [-C | -c] <commit> = like \"squash\" but keep only the previous
514 commit's log message, unless -C is used, in which case
515 keep only this commit's message; -c is same as -C but
516 opens the editor
517x, exec <command> = run command (the rest of the line) using shell
518b, break = stop here (continue rebase later with 'git rebase --continue')
519d, drop <commit> = remove commit
520l, label <label> = label current HEAD with a name
521t, reset <label> = reset HEAD to a label
522m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
523 create a merge commit using the original merge commit's
524 message (or the oneline, if no original merge commit was
525 specified); use -c <commit> to reword the commit message
526u, update-ref <ref> = track a placeholder for the <ref> to be updated
527 to this position in the new commits. The <ref> is
528 updated at the end of the rebase
529
530These lines can be re-ordered; they are executed from top to bottom.
531";
532
533fn add_commented_lines(buf: &mut String, text: &str, comment: char) {
534 for line in text.split_inclusive('\n') {
535 let body = line.strip_suffix('\n');
536 let content = body.unwrap_or(line);
537 if content.is_empty() {
538 buf.push(comment);
539 } else {
540 buf.push(comment);
541 buf.push(' ');
542 buf.push_str(content);
543 }
544 buf.push('\n');
545 }
546}
547
548pub fn append_todo_help(
553 buf: &mut String,
554 command_count: usize,
555 shortrevisions: Option<&str>,
556 shortonto: Option<&str>,
557 comment: char,
558 check_level_error: bool,
559) {
560 let edit_todo = !(shortrevisions.is_some() && shortonto.is_some());
561 if !edit_todo {
562 buf.push('\n');
563 let plural = if command_count == 1 {
564 "command"
565 } else {
566 "commands"
567 };
568 buf.push(comment);
569 buf.push(' ');
570 buf.push_str(&format!(
571 "Rebase {} onto {} ({command_count} {plural})\n",
572 shortrevisions.unwrap_or_default(),
573 shortonto.unwrap_or_default()
574 ));
575 }
576 add_commented_lines(buf, TODO_HELP_COMMANDS, comment);
577 let msg = if check_level_error {
578 "\nDo not remove any line. Use 'drop' explicitly to remove a commit.\n"
579 } else {
580 "\nIf you remove a line here THAT COMMIT WILL BE LOST.\n"
581 };
582 add_commented_lines(buf, msg, comment);
583 let msg = if edit_todo {
584 "\nYou are editing the todo file of an ongoing interactive rebase.\nTo continue rebase after editing, run:\n git rebase --continue\n\n"
585 } else {
586 "\nHowever, if you remove everything, the rebase will be aborted.\n\n"
587 };
588 add_commented_lines(buf, msg, comment);
589}
590
591pub fn merge_dir(git_dir: &Path) -> PathBuf {
596 git_dir.join("rebase-merge")
597}
598
599pub fn state_path(git_dir: &Path, name: &str) -> PathBuf {
600 merge_dir(git_dir).join(name)
601}
602
603pub fn in_progress(git_dir: &Path) -> bool {
604 merge_dir(git_dir).is_dir()
605}
606
607pub fn read_state_line(git_dir: &Path, name: &str) -> Option<String> {
609 let text = fs::read_to_string(state_path(git_dir, name)).ok()?;
610 Some(text.trim_end_matches('\n').to_string())
611}
612
613pub fn write_state_file(git_dir: &Path, name: &str, contents: &str) -> std::io::Result<()> {
614 fs::write(state_path(git_dir, name), contents)
615}
616
617pub fn remove_merge_state(git_dir: &Path) {
618 let _ = fs::remove_dir_all(merge_dir(git_dir));
619}
620
621fn sq_quote(value: &str) -> String {
624 let mut out = String::with_capacity(value.len() + 2);
625 out.push('\'');
626 for c in value.chars() {
627 if c == '\'' || c == '!' {
628 out.push('\'');
629 out.push('\\');
630 out.push(c);
631 out.push('\'');
632 } else {
633 out.push(c);
634 }
635 }
636 out.push('\'');
637 out
638}
639
640pub fn format_author_script(author: &[u8]) -> Option<String> {
643 let text = String::from_utf8_lossy(author);
644 let open = text.find('<')?;
645 let close = text[open..].find('>')? + open;
646 let name = text[..open].trim_end();
647 let email = &text[open + 1..close];
648 let date = text[close + 1..].trim();
649 Some(format!(
650 "GIT_AUTHOR_NAME={}\nGIT_AUTHOR_EMAIL={}\nGIT_AUTHOR_DATE={}\n",
651 sq_quote(name),
652 sq_quote(email),
653 sq_quote(&format!("@{date}"))
654 ))
655}
656
657pub fn parse_author_script(text: &str) -> Option<(String, String, String)> {
660 let mut name = None;
661 let mut email = None;
662 let mut date = None;
663 for line in text.lines() {
664 let (key, value) = line.split_once('=')?;
665 let value = sq_dequote(value)?;
666 match key {
667 "GIT_AUTHOR_NAME" => name = Some(value),
668 "GIT_AUTHOR_EMAIL" => email = Some(value),
669 "GIT_AUTHOR_DATE" => date = Some(value),
670 _ => return None,
671 }
672 }
673 Some((name?, email?, date?))
674}
675
676fn sq_dequote(value: &str) -> Option<String> {
677 let mut out = String::new();
678 let mut chars = value.chars().peekable();
679 if chars.next()? != '\'' {
680 return None;
681 }
682 loop {
683 let c = chars.next()?;
684 if c == '\'' {
685 match chars.peek() {
686 None => return Some(out),
687 Some('\\') => {
688 chars.next();
689 let escaped = chars.next()?;
690 out.push(escaped);
691 if chars.next()? != '\'' {
692 return None;
693 }
694 }
695 Some(_) => return None,
696 }
697 } else {
698 out.push(c);
699 }
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use sley_core::ObjectFormat;
707
708 fn oid(hex: &str) -> ObjectId {
709 ObjectId::from_hex(ObjectFormat::Sha1, hex).expect("test operation should succeed")
710 }
711
712 fn resolver(token: &str) -> TodoOidLookup {
713 if token.len() >= 7 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
714 TodoOidLookup::Commit {
715 oid: oid("21b83cd2e8f4d6d8d9615779ebaa801ba891eb04"),
716 parents: 1,
717 }
718 } else {
719 TodoOidLookup::Missing
720 }
721 }
722
723 #[test]
724 fn parses_commands_and_nicks() {
725 let text = "pick 21b83cd # one\nr 21b83cd # two\nbreak\nexec make test\n# comment\n\ndrop 21b83cd # three\n";
726 let (items, messages) = parse_todo_buffer(text, false, '#', &mut resolver);
727 assert!(messages.is_empty(), "{messages:?}");
728 let commands: Vec<TodoCommand> = items.iter().map(|item| item.command).collect();
729 assert_eq!(
730 commands,
731 vec![
732 TodoCommand::Pick,
733 TodoCommand::Reword,
734 TodoCommand::Break,
735 TodoCommand::Exec,
736 TodoCommand::Comment,
737 TodoCommand::Comment,
738 TodoCommand::Drop,
739 ]
740 );
741 assert_eq!(items[0].arg, "# one");
742 assert_eq!(items[3].arg, "make test");
743 assert_eq!(items[0].raw, "pick 21b83cd # one");
744 }
745
746 #[test]
747 fn flags_bad_lines_in_order() {
748 let (_, messages) = parse_todo_buffer("pickled 21b83cd # x\n", false, '#', &mut resolver);
749 assert_eq!(
750 messages,
751 vec![
752 "error: invalid command 'pickled'".to_string(),
753 "error: invalid line 1: pickled 21b83cd # x".to_string(),
754 ]
755 );
756 let (_, messages) = parse_todo_buffer("pick nope # x\n", false, '#', &mut resolver);
757 assert_eq!(
758 messages,
759 vec![
760 "error: could not parse 'nope'".to_string(),
761 "error: invalid line 1: pick nope # x".to_string(),
762 ]
763 );
764 let (_, messages) = parse_todo_buffer("fixup 21b83cd # x\n", false, '#', &mut resolver);
765 assert_eq!(
766 messages,
767 vec!["error: cannot 'fixup' without a previous commit".to_string()]
768 );
769 }
770
771 #[test]
772 fn fixup_flags_parse() {
773 let (items, messages) = parse_todo_buffer(
774 "pick 21b83cd # a\nfixup -C 21b83cd # b\nfixup -c 21b83cd # c\n",
775 false,
776 '#',
777 &mut resolver,
778 );
779 assert!(messages.is_empty());
780 assert_eq!(items[1].flags, FLAG_REPLACE_FIXUP_MSG);
781 assert_eq!(items[2].flags, FLAG_EDIT_FIXUP_MSG);
782 assert_eq!(
783 todo_item_to_string(&items[1], Some("21b83cd")),
784 "fixup -C 21b83cd # b"
785 );
786 }
787
788 #[test]
789 fn validates_labels_and_update_refs() {
790 let (_, messages) = parse_todo_buffer(
791 "label #\nlabel :invalid\nupdate-ref :bad\nupdate-ref topic\nupdate-ref refs/heads/topic\n",
792 false,
793 '#',
794 &mut resolver,
795 );
796 assert_eq!(
797 messages,
798 vec![
799 "error: '#' is not a valid label".to_string(),
800 "error: invalid line 1: label #".to_string(),
801 "error: ':invalid' is not a valid label".to_string(),
802 "error: invalid line 2: label :invalid".to_string(),
803 "error: ':bad' is not a valid refname".to_string(),
804 "error: invalid line 3: update-ref :bad".to_string(),
805 "error: update-ref requires a fully qualified refname e.g. refs/heads/topic"
806 .to_string(),
807 "error: invalid line 4: update-ref topic".to_string(),
808 ]
809 );
810 }
811
812 #[test]
813 fn todo_help_initial_variant() {
814 let mut buf = String::new();
815 append_todo_help(&mut buf, 2, Some("123..456"), Some("123"), '#', false);
816 assert!(buf.starts_with("\n# Rebase 123..456 onto 123 (2 commands)\n"));
817 assert!(buf.contains("# p, pick <commit> = use commit\n"));
818 assert!(buf.contains("# However, if you remove everything, the rebase will be aborted.\n"));
819 assert!(buf.ends_with("#\n"));
820 }
821
822 #[test]
823 fn author_script_round_trips() {
824 let script = format_author_script(b"A U Thor <a@example.com> 1234567890 +0100")
825 .expect("test operation should succeed");
826 assert_eq!(
827 script,
828 "GIT_AUTHOR_NAME='A U Thor'\nGIT_AUTHOR_EMAIL='a@example.com'\nGIT_AUTHOR_DATE='@1234567890 +0100'\n"
829 );
830 let (name, email, date) =
831 parse_author_script(&script).expect("test operation should succeed");
832 assert_eq!(name, "A U Thor");
833 assert_eq!(email, "a@example.com");
834 assert_eq!(date, "@1234567890 +0100");
835 }
836}