Skip to main content

sley_sequencer/
rebase.rs

1//! The interactive-rebase todo-list state machine (`.git/rebase-merge/`).
2//!
3//! Owns the on-disk contract of git's `sequencer.c` for the rebase half: the
4//! todo instruction sheet (parse + serialize, including the editor help
5//! block), the `rebase-merge` state files (`done`, `msgnum`, `end`,
6//! `head-name`, `onto`, `orig-head`, `amend`, `stopped-sha`, `autostash`,
7//! `author-script`, fixup/squash message scratch files), and the
8//! `author-script` quoting rules. The drive loop (merging trees, committing,
9//! editors) lives with the CLI porcelain.
10
11use sley_core::ObjectId;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15/// `todo_command_info` order matters: parsing tries commands in this order.
16#[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
35/// `TODO_EDIT_MERGE_MSG`
36pub const FLAG_EDIT_MERGE_MSG: u8 = 1 << 0;
37/// `TODO_REPLACE_FIXUP_MSG` (`fixup -C`)
38pub const FLAG_REPLACE_FIXUP_MSG: u8 = 1 << 1;
39/// `TODO_EDIT_FIXUP_MSG` (`fixup -c`)
40pub 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    /// `is_noop`: commands at or after `TODO_NOOP` in the upstream enum.
99    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    /// Creates a (non-merge) commit.
111    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/// One parsed instruction-sheet entry. `raw` preserves the exact line bytes
125/// (without the newline) so `save_todo` / `done` writes stay byte-faithful.
126#[derive(Debug, Clone)]
127pub struct RebaseTodoItem {
128    pub command: TodoCommand,
129    pub flags: u8,
130    /// Resolved commit for commands that name one.
131    pub oid: Option<ObjectId>,
132    /// The argument text after the object name (or the full argument for
133    /// exec/label/reset/merge-without-commit/update-ref; the line text for
134    /// comments).
135    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
151/// Outcome of resolving an object name on a todo line.
152pub enum TodoOidLookup {
153    /// Resolved; `parents` is the commit's parent count (merge detection).
154    Commit { oid: ObjectId, parents: usize },
155    /// Name did not resolve to a commit.
156    Missing,
157}
158
159/// A formatted message produced while parsing (printed verbatim by the
160/// porcelain, already carrying the `error: ` / `hint: ` prefix).
161pub type TodoParseMessages = Vec<String>;
162
163/// `is_command`: full command word or one-char nick followed by
164/// space/tab/EOL; returns the remainder.
165fn 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
185/// Parse the instruction sheet (`todo_list_parse_insn_buffer` +
186/// `parse_insn_line`). `resolve` maps an object-name token to a commit.
187/// Returns the items plus the ordered error/hint lines; an empty error list
188/// means the sheet parsed cleanly. Items that failed to parse are recorded as
189/// comments so totals/offsets still line up with upstream.
190pub 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        // `split` yields a final empty piece after a trailing newline; skip
203        // it (upstream iterates `*p` and stops at NUL).
204        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
344/// `check_merge_commit_insn`: the error + advice when a pick-like command
345/// names a merge commit.
346fn 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
395/// Serialize one todo item (`todo_list_to_strbuf` per-item logic).
396/// `oid_text` renders the commit id (short or full).
397pub 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
427/// The command legend appended below the todo list (`append_todo_help`).
428const 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
469/// `append_todo_help`. `shortrevisions`/`shortonto` present means the
470/// initial-edit variant (with the `Rebase a..b onto c` headline);
471/// absent means the `--edit-todo` variant. `check_level_error` selects the
472/// "Do not remove any line" warning text.
473pub 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
512// ---------------------------------------------------------------------------
513// State directory
514// ---------------------------------------------------------------------------
515
516pub 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
528/// Read a single-line state file, trimming the trailing newline.
529pub 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
542/// `sq_quote_buf`: wrap in single quotes, escaping embedded quotes
543/// (`'` becomes `'\''`).
544fn 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
561/// `write_author_script`: persist the stopped commit's author identity.
562/// `author` is the raw `Name <email> ts tz` identity line.
563pub 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
578/// Parse `author-script` back into the raw identity pieces
579/// (name, email, `@ts tz` date).
580pub 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}