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_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#[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 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 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
331fn 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#[must_use]
355#[derive(Debug)]
356enum PrepareMessagesResult {
357 EmptyMessage,
359
360 IdenticalMessage,
362
363 MismatchedCommits {
364 duplicates: Vec<String>,
365 missing: Vec<String>,
366 unexpected: Vec<String>,
367 },
368
369 Succeeded {
371 messages: HashMap<NonZeroOid, String>,
373 },
374}
375
376#[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 duplicates: Vec<String>,
565
566 messages: HashMap<NonZeroOid, String>,
568
569 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 [only_commit] if !message.contains("++ reword") => {
588 format!("++ reword {}\n{}", only_commit.get_short_oid()?, message)
589 }
590 _ => message,
591 };
592
593 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#[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 let subtree_roots = dag
637 .query_roots(commits)
638 .wrap_err("Computing subtree roots")?;
639
640 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#[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 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 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 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}