Skip to main content

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