1#![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#[derive(Debug)]
50pub enum InitialCommitMessages {
51 Discard,
53
54 FixUp(Revset),
56
57 Messages(Vec<String>),
59}
60
61#[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 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#[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 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]
329pub enum ResolveFixupCommitError {
331 MoreThanOneCommit {
333 revset_to_fixup: String,
335 commit_count: usize,
338 },
339
340 NotAnAncestor,
343}
344
345pub fn resolve_commit_to_fixup<'repo>(
347 repo: &'repo Repo,
348 dag: &mut Dag,
349 effects: &Effects,
350 revset_to_fixup: &Revset, resolve_revset_options: &ResolveRevsetOptions,
352 reworded_commits: Option<&[Commit<'_>]>, ) -> 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 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
383fn 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#[must_use]
407#[derive(Debug)]
408enum PrepareMessagesResult {
409 EmptyMessage,
411
412 IdenticalMessage,
414
415 MismatchedCommits {
416 duplicates: Vec<String>,
417 missing: Vec<String>,
418 unexpected: Vec<String>,
419 },
420
421 Succeeded {
423 messages: HashMap<NonZeroOid, String>,
425 },
426}
427
428#[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 duplicates: Vec<String>,
617
618 messages: HashMap<NonZeroOid, String>,
620
621 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 [only_commit] if !message.contains("++ reword") => {
640 format!("++ reword {}\n{}", only_commit.get_short_oid()?, message)
641 }
642 _ => message,
643 };
644
645 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#[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 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 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 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}