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    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    /// `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 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
423/// `check_merge_commit_insn`: the error + advice when a pick-like command
424/// names a merge commit.
425fn 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
474/// Serialize one todo item (`todo_list_to_strbuf` per-item logic).
475/// `oid_text` renders the commit id (short or full).
476pub 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
506/// The command legend appended below the todo list (`append_todo_help`).
507const 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
548/// `append_todo_help`. `shortrevisions`/`shortonto` present means the
549/// initial-edit variant (with the `Rebase a..b onto c` headline);
550/// absent means the `--edit-todo` variant. `check_level_error` selects the
551/// "Do not remove any line" warning text.
552pub 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
591// ---------------------------------------------------------------------------
592// State directory
593// ---------------------------------------------------------------------------
594
595pub 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
607/// Read a single-line state file, trimming the trailing newline.
608pub 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
621/// `sq_quote_buf`: wrap in single quotes, escaping embedded quotes
622/// (`'` becomes `'\''`).
623fn 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
640/// `write_author_script`: persist the stopped commit's author identity.
641/// `author` is the raw `Name <email> ts tz` identity line.
642pub 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
657/// Parse `author-script` back into the raw identity pieces
658/// (name, email, `@ts tz` date).
659pub 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}