git_branchless_reword/
lib.rs

1//! Update commit messages
2
3#![warn(missing_docs)]
4#![warn(
5    clippy::all,
6    clippy::as_conversions,
7    clippy::clone_on_ref_ptr,
8    clippy::dbg_macro
9)]
10#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)]
11
12pub mod dialoguer_edit;
13
14use lib::core::check_out::CheckOutCommitOptions;
15use lib::core::repo_ext::RepoExt;
16use lib::util::{ExitCode, EyreExitOr};
17use rayon::ThreadPoolBuilder;
18use std::collections::{HashMap, HashSet};
19
20use std::fmt::Write;
21use std::fs::File;
22use std::time::SystemTime;
23
24use bstr::{ByteSlice, ByteVec};
25use chrono::Local;
26use dialoguer_edit::Editor;
27
28use eyre::Context;
29use tracing::{instrument, warn};
30
31use lib::core::config::{
32    get_comment_char, get_commit_template, get_editor, get_restack_preserve_timestamps,
33};
34use lib::core::dag::{sorted_commit_set, union_all, CommitSet, Dag};
35use lib::core::effects::Effects;
36use lib::core::eventlog::{EventLogDb, EventReplayer};
37use lib::core::formatting::{Glyphs, Pluralize};
38use lib::core::node_descriptors::{render_node_descriptors, CommitOidDescriptor, NodeObject};
39use lib::core::rewrite::{
40    execute_rebase_plan, BuildRebasePlanOptions, ExecuteRebasePlanOptions, ExecuteRebasePlanResult,
41    RebasePlanBuilder, RebasePlanPermissions, RepoResource,
42};
43use lib::git::{message_prettify, Commit, GitRunInfo, MaybeZeroOid, NonZeroOid, Repo};
44
45use git_branchless_opts::{ResolveRevsetOptions, Revset};
46use git_branchless_revset::resolve_commits;
47
48/// The commit message(s) provided by the user.
49#[derive(Debug)]
50pub enum InitialCommitMessages {
51    /// The user wants to start with an empty (or template) message.
52    Discard,
53
54    /// The user wants to fixup a commit.
55    FixUp(Revset),
56
57    /// The user provided explicit messages.
58    Messages(Vec<String>),
59}
60
61/// Open the user's configured commit editor seeded with the provided message.
62#[instrument]
63pub fn edit_message(git_run_info: &GitRunInfo, repo: &Repo, message: &str) -> eyre::Result<String> {
64    let (mut editor, editor_program) = match get_editor(git_run_info, repo)? {
65        Some(editor_program) => {
66            let mut editor = Editor::new();
67            editor.executable(&editor_program);
68            (editor, editor_program)
69        }
70        None => (Editor::new(), "<default>".into()),
71    };
72    if editor_program == ":" {
73        // Special case in Git: treat `:` as a no-op editor.
74        return Ok(message.to_string());
75    }
76    let result = editor
77        .require_save(false)
78        .edit(message)
79        .with_context(|| format!("Invoking editor: '{}'", editor_program.to_string_lossy()))?
80        .expect("`Editor::edit` should not return `None` when `require_save` is `false`");
81    Ok(result)
82}
83
84/// Reword a commit and restack its descendants.
85#[instrument]
86pub fn reword(
87    effects: &Effects,
88    revsets: Vec<Revset>,
89    resolve_revset_options: &ResolveRevsetOptions,
90    messages: InitialCommitMessages,
91    git_run_info: &GitRunInfo,
92    force_rewrite_public_commits: bool,
93) -> EyreExitOr<()> {
94    let repo = Repo::from_current_dir()?;
95    let references_snapshot = repo.get_references_snapshot()?;
96    let conn = repo.get_db_conn()?;
97    let event_log_db = EventLogDb::new(&conn)?;
98    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
99    let event_cursor = event_replayer.make_default_cursor();
100    let mut dag = Dag::open_and_sync(
101        effects,
102        &repo,
103        &event_replayer,
104        event_cursor,
105        &references_snapshot,
106    )?;
107
108    let commits = match resolve_commits_from_hashes(
109        &repo,
110        &mut dag,
111        effects,
112        revsets,
113        resolve_revset_options,
114    )? {
115        Some(commits) => commits,
116        None => return Ok(Err(ExitCode(1))),
117    };
118    let build_options = BuildRebasePlanOptions {
119        force_rewrite_public_commits,
120        dump_rebase_constraints: false,
121        dump_rebase_plan: false,
122        detect_duplicate_commits_via_patch_id: false,
123    };
124    let permissions = match RebasePlanPermissions::verify_rewrite_set(
125        &dag,
126        build_options,
127        &commits.iter().map(|commit| commit.get_oid()).collect(),
128    )? {
129        Ok(permissions) => permissions,
130        Err(err) => {
131            err.describe(effects, &repo, &dag)?;
132            return Ok(Err(ExitCode(1)));
133        }
134    };
135
136    let messages = match messages {
137        InitialCommitMessages::Discard | InitialCommitMessages::Messages(_) => messages,
138        InitialCommitMessages::FixUp(revset) => {
139            let commits_to_fixup = resolve_commits_from_hashes(
140                &repo,
141                &mut dag,
142                effects,
143                vec![revset.clone()],
144                resolve_revset_options,
145            )?
146            .unwrap_or_default();
147            let commit_to_fixup = match commits_to_fixup.as_slice() {
148                [commit_to_fixup] => {
149                    let commits: CommitSet = commits.iter().map(|c| c.get_oid()).collect();
150                    if !dag.set_contains(
151                        &dag.query_common_ancestors(commits)?,
152                        commit_to_fixup.get_oid(),
153                    )? {
154                        writeln!(
155                            effects.get_error_stream(),
156                            "The commit supplied to --fixup must be an ancestor of all commits being reworded.\nAborting.",
157                        )?;
158                        return Ok(Err(ExitCode(1)));
159                    }
160                    commit_to_fixup
161                }
162                commits => {
163                    writeln!(
164                        effects.get_error_stream(),
165                        "--fixup expects exactly 1 commit, but '{}' evaluated to {}.\nAborting.",
166                        revset,
167                        commits.len()
168                    )?;
169                    return Ok(Err(ExitCode(1)));
170                }
171            };
172            let message = commit_to_fixup.get_summary()?.to_vec();
173            let message = format!("fixup! {}", message.into_string_lossy());
174            InitialCommitMessages::Messages(vec![message])
175        }
176    };
177
178    let edit_message_fn = |message: &str| edit_message(git_run_info, &repo, message);
179
180    let messages = match prepare_messages(&repo, messages, &commits, edit_message_fn)? {
181        PrepareMessagesResult::Succeeded { messages } => messages,
182        PrepareMessagesResult::IdenticalMessage => {
183            writeln!(
184                effects.get_output_stream(),
185                "Aborting. The message was not edited; nothing to do."
186            )?;
187            return Ok(Err(ExitCode(1)));
188        }
189        PrepareMessagesResult::EmptyMessage => {
190            writeln!(
191                effects.get_error_stream(),
192                "Aborting reword due to empty commit message."
193            )?;
194            return Ok(Err(ExitCode(1)));
195        }
196        PrepareMessagesResult::MismatchedCommits {
197            mut duplicates,
198            mut missing,
199            mut unexpected,
200        } => {
201            writeln!(
202                effects.get_error_stream(),
203                "Aborting reword due to mismatched inputs."
204            )?;
205            if !missing.is_empty() {
206                missing.sort_unstable();
207                writeln!(
208                    effects.get_error_stream(),
209                    "{} specified on the command line, but not found in the edited message:\n{}",
210                    Pluralize {
211                        determiner: Some(("This", "These")),
212                        amount: missing.len(),
213                        unit: ("commit was", "commits were"),
214                    },
215                    missing.join(", ")
216                )?;
217            }
218            if !unexpected.is_empty() {
219                unexpected.sort_unstable();
220                writeln!(
221                    effects.get_error_stream(),
222                    "{} found in the edited message, but {} not expected:\n{}",
223                    Pluralize {
224                        determiner: Some(("This", "These")),
225                        amount: unexpected.len(),
226                        unit: ("commit was", "commits were"),
227                    },
228                    match unexpected.len() {
229                        1 => "was",
230                        _ => "were",
231                    },
232                    unexpected.join(", ")
233                )?;
234            }
235            if !duplicates.is_empty() {
236                duplicates.sort_unstable();
237                writeln!(
238                    effects.get_error_stream(),
239                    "{} found in the edited message multiple times:\n{}",
240                    Pluralize {
241                        determiner: Some(("This", "These")),
242                        amount: duplicates.len(),
243                        unit: ("commit was", "commits were"),
244                    },
245                    duplicates.join(", ")
246                )?;
247            }
248            writeln!(
249                effects.get_error_stream(),
250                "Your edited message has been saved to .git/REWORD_EDITMSG for review and/or manual recovery."
251            )?;
252            return Ok(Err(ExitCode(1)));
253        }
254    };
255
256    let rebase_plan = {
257        let pool = ThreadPoolBuilder::new().build()?;
258        let repo_pool = RepoResource::new_pool(&repo)?;
259        let mut builder = RebasePlanBuilder::new(&dag, permissions);
260
261        for commit in commits.iter() {
262            let message = messages.get(&commit.get_oid()).unwrap();
263            // This looks funny, but just means "leave everything but the message as is"
264            let replacement_oid =
265                commit.amend_commit(None, None, None, Some(message.as_str()), None)?;
266            builder.move_subtree(commit.get_oid(), commit.get_parent_oids())?;
267            builder.replace_commit(commit.get_oid(), replacement_oid)?;
268        }
269
270        match builder.build(effects, &pool, &repo_pool)? {
271            Ok(Some(rebase_plan)) => rebase_plan,
272            Ok(None) => {
273                eyre::bail!(
274                    "BUG: rebase plan indicates nothing to do, but rewording should always do something."
275                );
276            }
277            Err(err) => {
278                err.describe(effects, &repo, &dag)?;
279                return Ok(Err(ExitCode(1)));
280            }
281        }
282    };
283
284    let now = SystemTime::now();
285    let event_tx_id = event_log_db.make_transaction_id(now, "reword")?;
286    let execute_options = ExecuteRebasePlanOptions {
287        now,
288        event_tx_id,
289        preserve_timestamps: get_restack_preserve_timestamps(&repo)?,
290        force_in_memory: true,
291        force_on_disk: false,
292        resolve_merge_conflicts: false,
293        check_out_commit_options: CheckOutCommitOptions {
294            additional_args: Default::default(),
295            reset: false,
296            render_smartlog: false,
297        },
298    };
299    let result = execute_rebase_plan(
300        effects,
301        git_run_info,
302        &repo,
303        &event_log_db,
304        &rebase_plan,
305        &execute_options,
306    )?;
307
308    match result {
309        ExecuteRebasePlanResult::Succeeded {
310            rewritten_oids: Some(rewritten_oids),
311        } => {
312            render_status_report(&repo, effects, &commits, &rewritten_oids)?;
313            Ok(Ok(()))
314        }
315        ExecuteRebasePlanResult::Succeeded {
316            rewritten_oids: None,
317        } => Ok(Ok(())),
318        ExecuteRebasePlanResult::DeclinedToMerge {
319            failed_merge_info: _,
320        } => {
321            writeln!(
322                effects.get_error_stream(),
323                "BUG: Merge failed, but rewording shouldn't cause any merge failures."
324            )?;
325            Ok(Err(ExitCode(1)))
326        }
327        ExecuteRebasePlanResult::Failed { exit_code } => Ok(Err(exit_code)),
328    }
329}
330
331/// Turn a list of ref-ish strings into a list of Commits.
332fn resolve_commits_from_hashes<'repo>(
333    repo: &'repo Repo,
334    dag: &mut Dag,
335    effects: &Effects,
336    revsets: Vec<Revset>,
337    resolve_revset_options: &ResolveRevsetOptions,
338) -> eyre::Result<Option<Vec<Commit<'repo>>>> {
339    let commit_sets = match resolve_commits(effects, repo, dag, &revsets, resolve_revset_options) {
340        Ok(commit_sets) => commit_sets,
341        Err(err) => {
342            err.describe(effects)?;
343            return Ok(None);
344        }
345    };
346
347    let commit_set = union_all(&commit_sets);
348    let commits = sorted_commit_set(repo, dag, &commit_set)?;
349
350    Ok(Some(commits))
351}
352
353/// The result of building the reword message.
354#[must_use]
355#[derive(Debug)]
356enum PrepareMessagesResult {
357    /// The reworded message was empty.
358    EmptyMessage,
359
360    /// The reworded message matches the original message.
361    IdenticalMessage,
362
363    MismatchedCommits {
364        duplicates: Vec<String>,
365        missing: Vec<String>,
366        unexpected: Vec<String>,
367    },
368
369    /// The reworded message was built successfully.
370    Succeeded {
371        /// The reworded messages for each commit.
372        messages: HashMap<NonZeroOid, String>,
373    },
374}
375
376/// Prepares the message(s) that will be used for rewording. These are mapped from each commit's
377/// NonZeroOid to the relevant message.
378#[instrument(skip(edit_message_fn))]
379fn prepare_messages(
380    repo: &Repo,
381    messages: InitialCommitMessages,
382    commits: &[Commit],
383    edit_message_fn: impl Fn(&str) -> eyre::Result<String>,
384) -> eyre::Result<PrepareMessagesResult> {
385    let comment_char = get_comment_char(repo)?;
386
387    let (message, load_editor, discard_messages) = match messages {
388        InitialCommitMessages::Discard => {
389            (get_commit_template(repo)?.unwrap_or_default(), true, true)
390        }
391        InitialCommitMessages::FixUp(_) => {
392            eyre::bail!("BUG: Fixup should have already been handled!")
393        }
394        InitialCommitMessages::Messages(ref messages) => {
395            let message = messages.clone().join("\n\n");
396            let message = message.trim();
397            (message.to_string(), message.is_empty(), false)
398        }
399    };
400
401    if !load_editor {
402        let message = message_prettify(message.as_str(), None)?;
403
404        if message.trim().is_empty() {
405            return Ok(PrepareMessagesResult::EmptyMessage);
406        }
407
408        let messages = commits
409            .iter()
410            .map(|commit| (commit.get_oid(), message.clone()))
411            .collect();
412
413        return Ok(PrepareMessagesResult::Succeeded { messages });
414    };
415
416    let possible_template_message = message.trim();
417    let possible_template_message = if possible_template_message.is_empty() {
418        String::from("\n")
419    } else {
420        format!("{possible_template_message}\n\n")
421    };
422    let possible_template_message = possible_template_message.as_str();
423    let discarded_message_header = format!("{comment_char} Original message:\n{comment_char} ");
424    let discarded_message_header = discarded_message_header.as_str();
425    let discarded_message_padding = format!("\n{comment_char} ");
426    let discarded_message_padding = discarded_message_padding.as_str();
427
428    let mut message = String::new();
429    for commit in commits.iter() {
430        let oid = commit.get_short_oid()?;
431
432        let original_message = commit
433            .get_message_raw()
434            .to_str()
435            .with_context(|| {
436                eyre::eyre!(
437                    "Could not decode commit message for commit: {:?}",
438                    commit.get_oid()
439                )
440            })?
441            .trim()
442            .to_string();
443
444        let msg = if discard_messages {
445            [
446                possible_template_message,
447                discarded_message_header,
448                original_message
449                    .split('\n')
450                    .collect::<Vec<&str>>()
451                    .join(discarded_message_padding)
452                    .as_str(),
453            ]
454            .concat()
455        } else {
456            original_message
457        };
458
459        let msg = if commits.len() == 1 {
460            format!("{msg}\n\n")
461        } else {
462            format!("++ reword {oid}\n{msg}\n\n")
463        };
464        message.push_str(msg.as_str());
465    }
466
467    message.push_str(
468        format!(
469            "\
470                {} Rewording: Please enter the commit {} to apply to {}.\n\
471                {} Lines starting with '{}' will be ignored, and an empty message aborts\n\
472                {} rewording.",
473            comment_char,
474            match commits.len() {
475                1 => "message",
476                _ => "messages",
477            },
478            Pluralize {
479                determiner: Some(("this", "these")),
480                amount: commits.len(),
481                unit: ("commit", "commits"),
482            },
483            comment_char,
484            comment_char,
485            comment_char,
486        )
487        .as_str(),
488    );
489
490    let edited_message = edit_message_fn(&message)?;
491    if edited_message == message {
492        return Ok(PrepareMessagesResult::IdenticalMessage);
493    }
494
495    let message = message_prettify(edited_message.as_str(), Some(comment_char))?;
496    if message.trim().is_empty() {
497        return Ok(PrepareMessagesResult::EmptyMessage);
498    }
499
500    let parsed_messages = parse_bulk_edit_message(message, commits, comment_char)?;
501
502    let input_oids: HashSet<NonZeroOid> = commits.iter().map(|c| c.get_oid()).collect();
503    let parsed_oids: HashSet<NonZeroOid> = parsed_messages.messages.keys().copied().collect();
504
505    if input_oids != parsed_oids
506        || !parsed_messages.duplicates.is_empty()
507        || !parsed_messages.unexpected.is_empty()
508    {
509        let commits: HashMap<NonZeroOid, &Commit> = commits
510            .iter()
511            .map(|commit| (commit.get_oid(), commit))
512            .collect();
513
514        let mut missing = Vec::new();
515        for oid in input_oids.difference(&parsed_oids) {
516            let short_oid = match commits.get(oid) {
517                Some(commit) => commit.get_short_oid()?,
518                None => eyre::bail!(
519                    "BUG: failed to retrieve known-good parsed OID from list of known-good input OIDs."
520                ),
521            };
522            missing.push(short_oid);
523        }
524
525        let mut w = File::create(repo.get_path().join("REWORD_EDITMSG"))
526            .context("Creating REWORD_EDITMSG file")?;
527        use std::io::Write;
528        writeln!(
529            &mut w,
530            "{} This file was created by `git branchless reword` at {}\n\
531        {} You can use it to recover any edits you had made to the included commit {}.\n\
532        {} If you don't need (or don't recognize) these edits, it is safe to delete this file.\n\
533        \n\
534        {}
535        ",
536            comment_char,
537            Local::now().to_rfc2822(),
538            comment_char,
539            if commits.len() == 1 {
540                "message"
541            } else {
542                "messages"
543            },
544            comment_char,
545            edited_message
546        )?;
547
548        return Ok(PrepareMessagesResult::MismatchedCommits {
549            duplicates: parsed_messages.duplicates,
550            missing,
551            unexpected: parsed_messages.unexpected,
552        });
553    }
554
555    Ok(PrepareMessagesResult::Succeeded {
556        messages: parsed_messages.messages,
557    })
558}
559
560#[must_use]
561#[derive(Debug)]
562struct ParseMessageResult {
563    /// Commit hashes that were found multiple times while parsing the edited messages.
564    duplicates: Vec<String>,
565
566    /// The parsed, formatted messages for rewording.
567    messages: HashMap<NonZeroOid, String>,
568
569    /// Commit hashes that were found while parsing the edited messages, but which were not
570    /// specified on the command line.
571    unexpected: Vec<String>,
572}
573
574#[instrument]
575fn parse_bulk_edit_message(
576    message: String,
577    commits: &[Commit],
578    comment_char: char,
579) -> eyre::Result<ParseMessageResult> {
580    let mut commits_oids = HashMap::new();
581    for commit in commits.iter() {
582        commits_oids.insert(commit.get_short_oid()?, commit.get_oid());
583    }
584
585    let message = match commits {
586        // For single commits, add the marker line, but only if the user hasn't already done so.
587        [only_commit] if !message.contains("++ reword") => {
588            format!("++ reword {}\n{}", only_commit.get_short_oid()?, message)
589        }
590        _ => message,
591    };
592
593    // split the bulk message into (hash, msg) tuples
594    let msgs = message
595        .split("++ reword")
596        .filter_map(|msg| msg.split_once('\n'))
597        .map(|(hash, msg)| (hash.trim(), msg));
598
599    let mut duplicates = Vec::new();
600    let mut messages = HashMap::new();
601    let mut unexpected = Vec::new();
602    for (hash, msg) in msgs {
603        let oid = match commits_oids.get(hash) {
604            Some(commit) => *commit,
605            None => {
606                unexpected.push(hash.to_string());
607                continue;
608            }
609        };
610        if messages.contains_key(&oid) {
611            duplicates.push(hash.to_string());
612            continue;
613        }
614        messages.insert(oid, message_prettify(msg, Some(comment_char))?);
615    }
616
617    Ok(ParseMessageResult {
618        duplicates,
619        messages,
620        unexpected,
621    })
622}
623
624/// Return the root commits for given a list of commits. This is the list of commits that have *no*
625/// ancestors also in the list. The idea is to find the minimum number of subtrees that much be
626/// rebased to include all of our rewording.
627#[instrument]
628fn find_subtree_roots<'repo>(
629    repo: &'repo Repo,
630    dag: &Dag,
631    commits: &[Commit],
632) -> eyre::Result<Vec<Commit<'repo>>> {
633    let commits: CommitSet = commits.iter().map(|commit| commit.get_oid()).collect();
634
635    // Find the vertices representing the roots of this set of commits
636    let subtree_roots = dag
637        .query_roots(commits)
638        .wrap_err("Computing subtree roots")?;
639
640    // convert the vertices back into actual Commits
641    let root_commits = dag
642        .commit_set_to_vec(&subtree_roots)?
643        .into_iter()
644        .filter_map(|oid| repo.find_commit(oid).ok()?)
645        .collect();
646
647    Ok(root_commits)
648}
649
650/// Print a basic status report of what commits were reworded.
651#[instrument]
652fn render_status_report(
653    repo: &Repo,
654    effects: &Effects,
655    commits: &[Commit],
656    rewritten_oids: &HashMap<NonZeroOid, MaybeZeroOid>,
657) -> eyre::Result<()> {
658    let glyphs = Glyphs::detect();
659    let num_commits = commits.len();
660    for original_commit in commits {
661        let replacement_oid = match rewritten_oids.get(&original_commit.get_oid()) {
662            Some(MaybeZeroOid::NonZero(new_oid)) => new_oid,
663            Some(MaybeZeroOid::Zero) => {
664                warn!(
665                    "Encountered ZeroOid after success rewriting commit {}",
666                    original_commit.get_oid()
667                );
668                continue;
669            }
670            None => {
671                writeln!(
672                    effects.get_error_stream(),
673                    "Warning: Could not find rewritten commit for {}",
674                    original_commit.get_oid(),
675                )?;
676                continue;
677            }
678        };
679        let replacement_commit = repo.find_commit(*replacement_oid)?.unwrap();
680        writeln!(
681            effects.get_output_stream(),
682            "Reworded commit {} as {}",
683            glyphs.render(
684                // Commit doesn't offer `friendly_describe_oid`, so we'll do it ourselves
685                render_node_descriptors(
686                    &glyphs,
687                    &NodeObject::Commit {
688                        commit: original_commit.clone(),
689                    },
690                    &mut [&mut CommitOidDescriptor::new(true)?],
691                )?
692            )?,
693            glyphs.render(replacement_commit.friendly_describe(&glyphs)?)?
694        )?;
695    }
696
697    if num_commits != 1 {
698        writeln!(
699            effects.get_output_stream(),
700            "Reworded {num_commits} commits. If this was unintentional, run: git undo",
701        )?;
702    }
703
704    Ok(())
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use lib::testing::make_git;
711    use std::collections::BTreeMap;
712
713    #[test]
714    fn test_reword_uses_commit_template() -> eyre::Result<()> {
715        let git = make_git()?;
716        git.init_repo()?;
717        let repo = git.get_repo()?;
718
719        let head_oid = git.commit_file("test1", 1)?;
720        let head_commit = repo.find_commit_or_fail(head_oid)?;
721
722        {
723            let result = prepare_messages(
724                &repo,
725                InitialCommitMessages::Discard,
726                &[head_commit.clone()],
727                |message| {
728                    insta::assert_snapshot!(message.trim(), @r###"
729                    # Original message:
730                    # create test1.txt
731
732                    # Rewording: Please enter the commit message to apply to this 1 commit.
733                    # Lines starting with '#' will be ignored, and an empty message aborts
734                    # rewording.
735                    "###);
736                    Ok(message.to_string())
737                },
738            )?;
739            insta::assert_debug_snapshot!(result, @"IdenticalMessage");
740        }
741
742        git.run(&["config", "commit.template", "template.txt"])?;
743        git.write_file_txt(
744            "template",
745            "\
746This is a template!
747",
748        )?;
749
750        {
751            let result = prepare_messages(
752                &repo,
753                InitialCommitMessages::Discard,
754                &[head_commit],
755                |message| {
756                    insta::assert_snapshot!(message.trim(), @r###"
757                    This is a template!
758
759                    # Original message:
760                    # create test1.txt
761
762                    # Rewording: Please enter the commit message to apply to this 1 commit.
763                    # Lines starting with '#' will be ignored, and an empty message aborts
764                    # rewording.
765                    "###);
766                    Ok(message.to_string())
767                },
768            )?;
769            insta::assert_debug_snapshot!(result, @"IdenticalMessage");
770        }
771
772        Ok(())
773    }
774
775    #[test]
776    fn test_reword_builds_multi_commit_messages() -> eyre::Result<()> {
777        let git = make_git()?;
778        git.init_repo()?;
779        let repo = git.get_repo()?;
780
781        let test1_oid = git.commit_file("test1", 1)?;
782        let test2_oid = git.commit_file("test2", 2)?;
783        let test1_commit = repo.find_commit_or_fail(test1_oid)?;
784        let test2_commit = repo.find_commit_or_fail(test2_oid)?;
785
786        {
787            let result = prepare_messages(
788                &repo,
789                InitialCommitMessages::Messages([].to_vec()),
790                &[test1_commit.clone(), test2_commit.clone()],
791                |message| {
792                    insta::assert_snapshot!(message.trim(), @r###"
793                    ++ reword 62fc20d
794                    create test1.txt
795
796                    ++ reword 96d1c37
797                    create test2.txt
798
799                    # Rewording: Please enter the commit messages to apply to these 2 commits.
800                    # Lines starting with '#' will be ignored, and an empty message aborts
801                    # rewording.
802                    "###);
803                    Ok(message.to_string())
804                },
805            )?;
806            insta::assert_debug_snapshot!(result, @"IdenticalMessage");
807        }
808
809        Ok(())
810    }
811
812    #[test]
813    fn test_reword_parses_bulk_edit_message() -> eyre::Result<()> {
814        let git = make_git()?;
815        git.init_repo()?;
816        let repo = git.get_repo()?;
817
818        let test1_oid = git.commit_file("test1", 1)?;
819        let test2_oid = git.commit_file("test2", 2)?;
820        let test1_commit = repo.find_commit_or_fail(test1_oid)?;
821        let test2_commit = repo.find_commit_or_fail(test2_oid)?;
822
823        {
824            let mut result = parse_bulk_edit_message(
825                String::from(
826                    "++ reword 62fc20d\n\
827                create test1.txt\n\
828                \n\
829                ++ reword 96d1c37\n\
830                create test2.txt\n",
831                ),
832                &[test1_commit.clone(), test2_commit.clone()],
833                '#',
834            )?;
835
836            // Convert the messages HashMap into the sorted map for testing
837            let messages: BTreeMap<_, _> = result.messages.iter().collect();
838            insta::assert_debug_snapshot!(messages, @r###"
839                {
840                    NonZeroOid(62fc20d2a290daea0d52bdc2ed2ad4be6491010e): "create test1.txt\n",
841                    NonZeroOid(96d1c37a3d4363611c49f7e52186e189a04c531f): "create test2.txt\n",
842                }"###
843            );
844
845            // clear the messages map b/c its contents have already been tested
846            result.messages.clear();
847            insta::assert_debug_snapshot!(result, @r###"
848                ParseMessageResult {
849                    duplicates: [],
850                    messages: {},
851                    unexpected: [],
852                }"###
853            );
854        };
855
856        Ok(())
857    }
858
859    #[test]
860    fn test_reword_parses_unexpected_and_duplicate_commit_hashs() -> eyre::Result<()> {
861        let git = make_git()?;
862        git.init_repo()?;
863        let repo = git.get_repo()?;
864
865        let test1_oid = git.commit_file("test1", 1)?;
866        let test1_commit = repo.find_commit_or_fail(test1_oid)?;
867
868        {
869            let result = parse_bulk_edit_message(
870                String::from(
871                    "++ reword 62fc20d\n\
872                create test1.txt\n\
873                \n\
874                ++ reword abc123\n\
875                this commit doesn't exist\n\
876                \n\
877                ++ reword 62fc20d\n\
878                this commit has been duplicated\n\
879                \n",
880                ),
881                &[test1_commit.clone()],
882                '#',
883            )?;
884
885            insta::assert_debug_snapshot!(result, @r###"
886                ParseMessageResult {
887                    duplicates: [
888                        "62fc20d",
889                    ],
890                    messages: {
891                        NonZeroOid(62fc20d2a290daea0d52bdc2ed2ad4be6491010e): "create test1.txt\n",
892                    },
893                    unexpected: [
894                        "abc123",
895                    ],
896                }"###
897            );
898        };
899
900        Ok(())
901    }
902}