1#[macro_use]
2extern crate slog;
3use anyhow::{anyhow, Result};
4
5mod commute;
6mod config;
7mod owned;
8mod stack;
9
10use git2::DiffStats;
11use std::io::Write;
12use std::path::Path;
13
14pub struct Config<'a> {
15 pub dry_run: bool,
16 pub no_limit: bool,
17 pub force_author: bool,
18 pub force_detach: bool,
19 pub base: Option<&'a str>,
20 pub and_rebase: bool,
21 pub rebase_options: &'a Vec<&'a str>,
22 pub whole_file: bool,
23 pub one_fixup_per_commit: bool,
24 pub squash: bool,
25 pub message: Option<&'a str>,
26}
27
28pub fn run(logger: &slog::Logger, config: &Config) -> Result<()> {
29 let repo = git2::Repository::open_from_env()?;
30 debug!(logger, "repository found"; "path" => repo.path().to_str());
31
32 run_with_repo(logger, config, &repo)
33}
34
35fn run_with_repo(logger: &slog::Logger, config: &Config, repo: &git2::Repository) -> Result<()> {
36 let config = config::unify(config, repo);
37
38 if !config.rebase_options.is_empty() && !config.and_rebase {
39 return Err(anyhow!(
40 "REBASE_OPTIONS were specified without --and-rebase flag"
41 ));
42 }
43
44 let mut we_added_everything_to_index = false;
45 if nothing_left_in_index(repo)? {
46 if config::auto_stage_if_nothing_staged(repo) {
47 let pathspec = ["."];
50 let mut index = repo.index()?;
51 index.add_all(pathspec.iter(), git2::IndexAddOption::DEFAULT, None)?;
52 index.write()?;
53
54 if nothing_left_in_index(repo)? {
55 announce(logger, Announcement::NothingStagedAfterAutoStaging);
56 return Ok(());
57 }
58
59 we_added_everything_to_index = true;
60 } else {
61 announce(logger, Announcement::NothingStaged);
62 return Ok(());
63 }
64 }
65
66 let (stack, stack_end_reason) = stack::working_stack(
67 repo,
68 config.no_limit,
69 config.base,
70 config.force_author,
71 config.force_detach,
72 logger,
73 )?;
74
75 let mut diff_options = Some({
76 let mut ret = git2::DiffOptions::new();
77 ret.context_lines(0)
78 .id_abbrev(40)
79 .ignore_filemode(true)
80 .ignore_submodules(true);
81 ret
82 });
83
84 let (stack, summary_counts): (Vec<_>, _) = {
85 let mut diffs = Vec::with_capacity(stack.len());
86 for commit in &stack {
87 let diff = owned::Diff::new(
88 &repo.diff_tree_to_tree(
89 if commit.parents().len() == 0 {
90 None
91 } else {
92 Some(commit.parent(0)?.tree()?)
93 }
94 .as_ref(),
95 Some(&commit.tree()?),
96 diff_options.as_mut(),
97 )?,
98 )?;
99 trace!(logger, "parsed commit diff";
100 "commit" => commit.id().to_string(),
101 "diff" => format!("{:?}", diff),
102 );
103 diffs.push(diff);
104 }
105
106 let summary_counts = stack::summary_counts(&stack);
107 (stack.into_iter().zip(diffs).collect(), summary_counts)
108 };
109
110 let mut head_tree = repo.head()?.peel_to_tree()?;
111 let index = owned::Diff::new(&repo.diff_tree_to_index(
112 Some(&head_tree),
113 None,
114 diff_options.as_mut(),
115 )?)?;
116 trace!(logger, "parsed index";
117 "index" => format!("{:?}", index),
118 );
119
120 let signature = repo
121 .signature()
122 .or_else(|_| git2::Signature::now("nobody", "nobody@example.com"))?;
123 let mut head_commit = repo.head()?.peel_to_commit()?;
124
125 let mut hunks_with_commit = vec![];
126
127 let mut modified_hunks_without_target = 0usize;
128 let mut non_modified_patches = 0usize;
129 'patch: for index_patch in index.iter() {
130 let old_path = index_patch.new_path.as_slice();
131 if index_patch.status != git2::Delta::Modified {
132 debug!(logger, "skipped non-modified patch";
133 "path" => String::from_utf8_lossy(old_path).into_owned(),
134 "status" => format!("{:?}", index_patch.status),
135 );
136 non_modified_patches += 1;
137 continue 'patch;
138 }
139
140 let mut preceding_hunks_offset = 0isize;
141 let mut applied_hunks_offset = 0isize;
142 'hunk: for index_hunk in &index_patch.hunks {
143 debug!(logger, "next hunk";
144 "header" => index_hunk.header(),
145 "path" => String::from_utf8_lossy(old_path).into_owned(),
146 );
147
148 let isolated_hunk = index_hunk
154 .clone()
155 .shift_added_block(-preceding_hunks_offset);
156
157 let hunk_to_apply = isolated_hunk
161 .clone()
162 .shift_both_blocks(applied_hunks_offset);
163
164 let hunk_offset = index_hunk.changed_offset();
166
167 debug!(logger, "";
189 "to apply" => hunk_to_apply.header(),
190 "to commute" => isolated_hunk.header(),
191 "preceding hunks" => format!("{}/{}", applied_hunks_offset, preceding_hunks_offset),
192 );
193
194 preceding_hunks_offset += hunk_offset;
195
196 let mut dest_commit = None;
198 let mut commuted_old_path = old_path;
199 let mut commuted_index_hunk = isolated_hunk;
200
201 'commit: for (commit, diff) in &stack {
202 let c_logger = logger.new(o!(
203 "commit" => commit.id().to_string(),
204 ));
205 let next_patch = match diff.by_new(commuted_old_path) {
206 Some(patch) => patch,
207 None => {
211 debug!(c_logger, "skipped commit with no path");
212 continue 'commit;
213 }
214 };
215
216 if config.whole_file {
221 debug!(
222 c_logger,
223 "Commit touches the hunk file and match whole file is enabled"
224 );
225 dest_commit = Some(commit);
226 break 'commit;
227 }
228
229 if next_patch.status == git2::Delta::Added {
230 debug!(c_logger, "found noncommutative commit by add");
231 dest_commit = Some(commit);
232 break 'commit;
233 }
234 if commuted_old_path != next_patch.old_path.as_slice() {
235 debug!(c_logger, "changed commute path";
236 "path" => String::from_utf8_lossy(&next_patch.old_path).into_owned(),
237 );
238 commuted_old_path = next_patch.old_path.as_slice();
239 }
240 commuted_index_hunk = match commute::commute_diff_before(
241 &commuted_index_hunk,
242 &next_patch.hunks,
243 ) {
244 Some(hunk) => {
245 debug!(c_logger, "commuted hunk with commit";
246 "offset" => (hunk.added.start as i64) - (commuted_index_hunk.added.start as i64),
247 );
248 hunk
249 }
250 None => {
253 debug!(c_logger, "found noncommutative commit by conflict");
254 dest_commit = Some(commit);
255 break 'commit;
256 }
257 };
258 }
259 let dest_commit = match dest_commit {
260 Some(commit) => commit,
261 None => {
264 modified_hunks_without_target += 1;
265 continue 'hunk;
266 }
267 };
268
269 let hunk_with_commit = HunkWithCommit {
270 hunk_to_apply,
271 dest_commit,
272 index_patch,
273 };
274 hunks_with_commit.push(hunk_with_commit);
275
276 applied_hunks_offset += hunk_offset;
277 }
278 }
279
280 let target_always_sha: bool = config::fixup_target_always_sha(repo);
281
282 if !config.dry_run {
283 repo.reference("PRE_ABSORB_HEAD", head_commit.id(), true, "")?;
284 }
285
286 for (current, next) in hunks_with_commit
294 .iter()
295 .zip(hunks_with_commit.iter().skip(1).map(Some).chain([None]))
296 {
297 let new_head_tree = apply_hunk_to_tree(
298 repo,
299 &head_tree,
300 ¤t.hunk_to_apply,
301 ¤t.index_patch.old_path,
302 )?;
303
304 let commit_fixup = next.map_or(true, |next| {
306 !config.one_fixup_per_commit || next.dest_commit.id() != current.dest_commit.id()
308 });
309 if commit_fixup {
310 let dest_commit_id = current.dest_commit.id().to_string();
315 let dest_commit_locator = match target_always_sha {
316 true => &dest_commit_id,
317 false => current
318 .dest_commit
319 .summary()
320 .filter(|&msg| summary_counts[msg] == 1)
321 .unwrap_or(&dest_commit_id),
322 };
323 let diff = repo
324 .diff_tree_to_tree(Some(&head_commit.tree()?), Some(&new_head_tree), None)?
325 .stats()?;
326 if !config.dry_run {
327 head_tree = new_head_tree;
328 let verb = if config.squash { "squash" } else { "fixup" };
329 let mut message = format!("{}! {}\n", verb, dest_commit_locator);
330 if let Some(m) = config.message.filter(|m| !m.is_empty()) {
331 message.push('\n');
332 message.push_str(m);
333 message.push('\n');
334 };
335 head_commit = repo.find_commit(repo.commit(
336 Some("HEAD"),
337 &signature,
338 &signature,
339 &message,
340 &head_tree,
341 &[&head_commit],
342 )?)?;
343 announce(
344 logger,
345 Announcement::Committed(&head_commit, dest_commit_locator, &diff),
346 );
347 } else {
348 announce(
349 logger,
350 Announcement::WouldHaveCommitted(dest_commit_locator, &diff),
351 );
352 }
353 } else {
354 head_tree = new_head_tree;
356 }
357 }
358
359 if we_added_everything_to_index {
360 let mut index = repo.index()?;
364 index.read_tree(&head_tree)?;
365 index.write()?;
366 }
367
368 if non_modified_patches == index.len() {
369 announce(logger, Announcement::NoFileModifications);
370 return Ok(());
371 }
372
373 if non_modified_patches > 0 && !we_added_everything_to_index {
379 announce(logger, Announcement::NonFileModifications);
380 }
381
382 if modified_hunks_without_target > 0 {
383 announce(logger, Announcement::FileModificationsWithoutTarget);
384
385 match stack_end_reason {
386 stack::StackEndReason::ReachedRoot => {
387 announce(logger, Announcement::CannotFixUpPastFirstCommit);
388 }
389 stack::StackEndReason::ReachedMergeCommit => {
390 let commit = match stack.last() {
391 Some(commit) => &commit.0,
392 None => &head_commit,
393 };
394 announce(logger, Announcement::CannotFixUpPastMerge(commit));
395 }
396 stack::StackEndReason::ReachedAnotherAuthor => {
397 let commit = match stack.last() {
398 Some(commit) => &commit.0,
399 None => &head_commit,
400 };
401 announce(logger, Announcement::WillNotFixUpPastAnotherAuthor(commit));
402 }
403 stack::StackEndReason::ReachedLimit => {
404 announce(
405 logger,
406 Announcement::WillNotFixUpPastStackLimit(config::max_stack(repo)),
407 );
408 }
409 stack::StackEndReason::CommitsHiddenByBase => {
410 announce(
411 logger,
412 Announcement::CommitsHiddenByBase(config.base.unwrap()),
413 );
414 }
415 stack::StackEndReason::CommitsHiddenByBranches => {
416 announce(logger, Announcement::CommitsHiddenByBranches);
417 }
418 }
419 }
420
421 if !hunks_with_commit.is_empty() {
422 use std::process::Command;
423 let last_commit_in_stack = &stack.last().unwrap().0;
425 let number_of_parents = last_commit_in_stack.parents().len();
427 assert!(number_of_parents <= 1);
428
429 let rebase_root = if number_of_parents == 0 {
430 "--root"
431 } else {
432 &*last_commit_in_stack.parent(0)?.id().to_string()
435 };
436
437 let rebase_args = [
438 "rebase",
439 "--interactive",
440 "--autosquash",
441 "--autostash",
442 rebase_root,
443 ];
444
445 if config.and_rebase {
446 let mut command = Command::new("git");
447
448 let repo_path = repo.workdir().and_then(Path::to_str);
454 match repo_path {
455 Some(path) => {
456 command.args(["-C", path]);
457 }
458 _ => {
459 announce(logger, Announcement::CouldNotFindRepositoryPath);
460 }
461 }
462
463 command.args(rebase_args);
464
465 for arg in config.rebase_options {
466 command.arg(arg);
467 }
468
469 if config.dry_run {
470 announce(logger, Announcement::WouldHaveRebased(&command));
471 } else {
472 debug!(logger, "running git rebase"; "command" => format!("{:?}", command));
473 command.status().expect("could not run git rebase");
476 }
477 } else if !config.dry_run {
478 announce(logger, Announcement::HowToSquash(rebase_args.join(" ")));
479 }
480 }
481
482 Ok(())
483}
484
485struct HunkWithCommit<'c, 'r, 'p> {
486 hunk_to_apply: owned::Hunk,
487 dest_commit: &'c git2::Commit<'r>,
488 index_patch: &'p owned::Patch,
489}
490
491fn apply_hunk_to_tree<'repo>(
492 repo: &'repo git2::Repository,
493 base: &git2::Tree,
494 hunk: &owned::Hunk,
495 path: &[u8],
496) -> Result<git2::Tree<'repo>> {
497 let mut treebuilder = repo.treebuilder(Some(base))?;
498
499 if let Some(slash) = path.iter().position(|&x| x == b'/') {
501 let (first, rest) = path.split_at(slash);
502 let rest = &rest[1..];
503
504 let (subtree, submode) = {
505 let entry = treebuilder
506 .get(first)?
507 .ok_or_else(|| anyhow!("couldn't find tree entry in tree for path"))?;
508 (repo.find_tree(entry.id())?, entry.filemode())
509 };
510 let result_subtree = apply_hunk_to_tree(repo, &subtree, hunk, rest)?;
512
513 treebuilder.insert(first, result_subtree.id(), submode)?;
514 return Ok(repo.find_tree(treebuilder.write()?)?);
515 }
516
517 let (blob, mode) = {
518 let entry = treebuilder
519 .get(path)?
520 .ok_or_else(|| anyhow!("couldn't find blob entry in tree for path"))?;
521 (repo.find_blob(entry.id())?, entry.filemode())
522 };
523
524 let mut blobwriter = repo.blob_writer(None)?;
528 let old_content = blob.content();
529 let (old_start, _, _, _) = hunk.anchors();
530
531 let old_content = {
534 let (pre, post) = split_lines_after(old_content, old_start);
535 blobwriter.write_all(pre)?;
536 post
537 };
538 for line in &*hunk.added.lines {
540 blobwriter.write_all(line)?;
541 }
542 let (_, old_content) = split_lines_after(old_content, hunk.removed.lines.len());
545 blobwriter.write_all(old_content)?;
547
548 treebuilder.insert(path, blobwriter.commit()?, mode)?;
549 Ok(repo.find_tree(treebuilder.write()?)?)
550}
551
552fn split_lines_after(content: &[u8], n: usize) -> (&[u8], &[u8]) {
554 let split_index = if n > 0 {
555 memchr::Memchr::new(b'\n', content)
556 .fuse() .nth(n - 1) .map(|x| x + 1)
559 .unwrap_or_else(|| content.len())
560 } else {
561 0
562 };
563 content.split_at(split_index)
564}
565
566fn nothing_left_in_index(repo: &git2::Repository) -> Result<bool> {
567 let stats = index_stats(repo)?;
568 let nothing = stats.files_changed() == 0 && stats.insertions() == 0 && stats.deletions() == 0;
569 Ok(nothing)
570}
571
572fn index_stats(repo: &git2::Repository) -> Result<git2::DiffStats> {
573 let head = repo.head()?.peel_to_tree()?;
574 let diff = repo.diff_tree_to_index(Some(&head), Some(&repo.index()?), None)?;
575 let stats = diff.stats()?;
576 Ok(stats)
577}
578
579enum Announcement<'r> {
581 Committed(&'r git2::Commit<'r>, &'r str, &'r git2::DiffStats),
582 WouldHaveCommitted(&'r str, &'r git2::DiffStats),
583 WouldHaveRebased(&'r std::process::Command),
584 HowToSquash(String),
585 NothingStagedAfterAutoStaging,
586 NothingStaged,
587 NoFileModifications,
588 NonFileModifications,
589 FileModificationsWithoutTarget,
590 CannotFixUpPastFirstCommit,
591 CannotFixUpPastMerge(&'r git2::Commit<'r>),
592 WillNotFixUpPastAnotherAuthor(&'r git2::Commit<'r>),
593 WillNotFixUpPastStackLimit(usize),
594 CommitsHiddenByBase(&'r str),
595 CommitsHiddenByBranches,
596 CouldNotFindRepositoryPath,
597}
598
599fn announce(logger: &slog::Logger, announcement: Announcement) {
600 match announcement {
601 Announcement::Committed(commit, destination, diff) => {
602 let commit_short_id = commit.as_object().short_id().unwrap();
603 let commit_short_id = commit_short_id
604 .as_str()
605 .expect("the commit short id is always a valid ASCII string");
606 let change_header = format_change_header(diff);
607
608 info!(
609 logger,
610 "committed";
611 "fixup" => destination,
612 "commit" => commit_short_id,
613 "header" => change_header,
614 );
615 }
616 Announcement::WouldHaveCommitted(fixup, diff) => info!(
617 logger,
618 "would have committed";
619 "fixup" => fixup,
620 "header" => format_change_header(diff),
621 ),
622 Announcement::WouldHaveRebased(command) => info!(
623 logger, "would have run git rebase"; "command" => format!("{:?}", command)
624 ),
625 Announcement::HowToSquash(rebase_args) => info!(
626 logger,
627 "To squash the new commits, rebase:";
628 "command" => format!("git {}", rebase_args),
629 ),
630 Announcement::NothingStagedAfterAutoStaging => warn!(
631 logger,
632 "No changes staged, even after auto-staging. Try adding something to the index.",
633 ),
634 Announcement::NothingStaged => warn!(
635 logger,
636 "No changes staged. Try adding something to the index or set {} = true.",
637 config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME
638 ),
639 Announcement::NoFileModifications => warn!(
640 logger,
641 "No changes were in-place file modifications. \
642 Added, removed, or renamed files cannot be automatically absorbed."
643 ),
644 Announcement::NonFileModifications => warn!(
645 logger,
646 "Some changes were not in-place file modifications. \
647 Added, removed, or renamed files cannot be automatically absorbed."
648 ),
649 Announcement::FileModificationsWithoutTarget => warn!(
650 logger,
651 "Some file modifications did not have an available commit to fix up. \
652 You will have to manually create fixup commits."
653 ),
654 Announcement::CannotFixUpPastFirstCommit => warn!(
655 logger,
656 "Cannot fix up past the first commit in the repository."
657 ),
658 Announcement::CannotFixUpPastMerge(commit) => warn!(
659 logger,
660 "Cannot fix up past a merge commit";
661 "commit" => commit.id().to_string()
662 ),
663 Announcement::WillNotFixUpPastAnotherAuthor(commit) => warn!(
664 logger,
665 "Will not fix up past commits by another author. Use --force-author to override";
666 "commit" => commit.id().to_string()
667 ),
668 Announcement::WillNotFixUpPastStackLimit(max_stack_limit) => warn!(
669 logger,
670 "Will not fix up past maximum stack limit. Use --base or configure {} to override",
671 config::MAX_STACK_CONFIG_NAME;
672 "limit" => max_stack_limit,
673 ),
674 Announcement::CommitsHiddenByBase(base) => warn!(
675 logger,
676 "Will not fix up past specified base commit. \
677 Consider using --base to specify a different base commit";
678 "base" => base,
679 ),
680 Announcement::CommitsHiddenByBranches => warn!(
681 logger,
682 "Will not fix up commits reachable by other branches. \
683 Use --base to specify a base commit."
684 ),
685 Announcement::CouldNotFindRepositoryPath => warn!(
686 logger,
687 "Could not determine repository path for rebase. Running in current directory."
688 ),
689 }
690}
691
692fn format_change_header(diff: &DiffStats) -> String {
693 let insertions = diff.insertions();
694 let deletions = diff.deletions();
695
696 let mut header = String::new();
697 if insertions > 0 {
698 header.push_str(&format!(
699 "{} {}(+)",
700 insertions,
701 if insertions == 1 {
702 "insertion"
703 } else {
704 "insertions"
705 }
706 ));
707 }
708 if deletions > 0 {
709 if !header.is_empty() {
710 header.push_str(", ");
711 }
712 header.push_str(&format!(
713 "{} {}(-)",
714 deletions,
715 if deletions == 1 {
716 "deletion"
717 } else {
718 "deletions"
719 }
720 ));
721 }
722 header
723}
724
725#[cfg(test)]
726mod tests {
727 use git2::message_trailers_strs;
728 use serde_json::json;
729 use std::path::PathBuf;
730 use tests::repo_utils::add;
731
732 use super::*;
733 mod log_utils;
734 pub mod repo_utils;
735
736 #[test]
737 fn no_commits_in_repo() {
738 let dir = tempfile::tempdir().unwrap();
739 let repo = git2::Repository::init_opts(
740 dir.path(),
741 git2::RepositoryInitOptions::new().initial_head("master"),
742 )
743 .unwrap();
744 let capturing_logger = log_utils::CapturingLogger::new();
745 let result = run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &repo);
746 assert!(result
747 .err()
748 .unwrap()
749 .to_string()
750 .starts_with("reference 'refs/heads/master' not found"));
751 }
752
753 #[test]
754 fn multiple_fixups_per_commit() {
755 let ctx = repo_utils::prepare_and_stage();
756
757 let actual_pre_absorb_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap().id();
758
759 let mut capturing_logger = log_utils::CapturingLogger::new();
761 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
762
763 let mut revwalk = ctx.repo.revwalk().unwrap();
764 revwalk.push_head().unwrap();
765 assert_eq!(revwalk.count(), 3);
766
767 assert!(nothing_left_in_index(&ctx.repo).unwrap());
768
769 let pre_absorb_ref_commit = ctx.repo.refname_to_id("PRE_ABSORB_HEAD").unwrap();
770 assert_eq!(pre_absorb_ref_commit, actual_pre_absorb_commit);
771
772 assert_eq!(
773 extract_commit_messages(&ctx.repo),
774 vec![
775 "fixup! Initial commit.\n",
776 "fixup! Initial commit.\n",
777 "Initial commit.",
778 ]
779 );
780
781 log_utils::assert_log_messages_are(
782 capturing_logger.visible_logs(),
783 vec![
784 &json!({
785 "level": "INFO",
786 "msg": "committed",
787 "fixup": "Initial commit.",
788 "header": "1 insertion(+)",
789 }),
790 &json!({
791 "level": "INFO",
792 "msg": "committed",
793 "fixup": "Initial commit.",
794 "header": "2 insertions(+)",
795 }),
796 &json!({
797 "level": "INFO",
798 "msg": "To squash the new commits, rebase:",
799 "command": "git rebase --interactive --autosquash --autostash --root",
800 }),
801 ],
802 );
803 }
804
805 #[test]
806 fn one_deletion() {
807 let (ctx, file_path) = repo_utils::prepare_repo();
808 std::fs::write(
809 ctx.join(&file_path),
810 br#"
811line
812line
813"#,
814 )
815 .unwrap();
816 add(&ctx.repo, &file_path);
817
818 let actual_pre_absorb_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap().id();
819
820 let mut capturing_logger = log_utils::CapturingLogger::new();
822 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
823
824 let mut revwalk = ctx.repo.revwalk().unwrap();
825 revwalk.push_head().unwrap();
826 assert_eq!(revwalk.count(), 2);
827
828 assert!(nothing_left_in_index(&ctx.repo).unwrap());
829
830 let pre_absorb_ref_commit = ctx.repo.refname_to_id("PRE_ABSORB_HEAD").unwrap();
831 assert_eq!(pre_absorb_ref_commit, actual_pre_absorb_commit);
832
833 log_utils::assert_log_messages_are(
834 capturing_logger.visible_logs(),
835 vec![
836 &json!({
837 "level": "INFO",
838 "msg": "committed",
839 "fixup": "Initial commit.",
840 "header": "3 deletions(-)",
841 }),
842 &json!({
843 "level": "INFO",
844 "msg": "To squash the new commits, rebase:",
845 "command": "git rebase --interactive --autosquash --autostash --root",
846 }),
847 ],
848 );
849 }
850
851 #[test]
852 fn one_insertion_and_one_deletion() {
853 let (ctx, file_path) = repo_utils::prepare_repo();
854 std::fs::write(
855 ctx.join(&file_path),
856 br#"
857line
858line
859
860even more
861lines
862"#,
863 )
864 .unwrap();
865 add(&ctx.repo, &file_path);
866
867 let actual_pre_absorb_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap().id();
868
869 let mut capturing_logger = log_utils::CapturingLogger::new();
871 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
872
873 let mut revwalk = ctx.repo.revwalk().unwrap();
874 revwalk.push_head().unwrap();
875 assert_eq!(revwalk.count(), 2);
876
877 assert!(nothing_left_in_index(&ctx.repo).unwrap());
878
879 let pre_absorb_ref_commit = ctx.repo.refname_to_id("PRE_ABSORB_HEAD").unwrap();
880 assert_eq!(pre_absorb_ref_commit, actual_pre_absorb_commit);
881
882 log_utils::assert_log_messages_are(
883 capturing_logger.visible_logs(),
884 vec![
885 &json!({
886 "level": "INFO",
887 "msg": "committed",
888 "fixup": "Initial commit.",
889 "header": "1 insertion(+), 1 deletion(-)",
890 }),
891 &json!({
892 "level": "INFO",
893 "msg": "To squash the new commits, rebase:",
894 "command": "git rebase --interactive --autosquash --autostash --root",
895 }),
896 ],
897 );
898 }
899
900 #[test]
901 fn exceed_stack_limit_with_modified_hunk() {
902 let (ctx, file_path) = repo_utils::prepare_repo();
903
904 let parent_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
905 repo_utils::empty_commit_chain(&ctx.repo, "HEAD", &[&parent_commit], config::MAX_STACK);
906 repo_utils::stage_file_changes(&ctx, &file_path);
907
908 let mut capturing_logger = log_utils::CapturingLogger::new();
910 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
911
912 let mut revwalk = ctx.repo.revwalk().unwrap();
913 revwalk.push_head().unwrap();
914 assert_eq!(
915 revwalk.count(),
916 config::MAX_STACK + 1,
917 "Wrong number of commits."
918 );
919
920 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
921 assert!(is_something_in_index);
922
923 log_utils::assert_log_messages_are(
924 capturing_logger.visible_logs(),
925 vec![
926 &json!({
927 "level": "WARN",
928 "msg": "Some file modifications did not have an available commit to fix up. \
929 You will have to manually create fixup commits.",
930 }),
931 &json!({
932 "level": "WARN",
933 "msg": format!(
934 "Will not fix up past maximum stack limit. \
935 Use --base or configure {} to override",
936 config::MAX_STACK_CONFIG_NAME
937 ),
938 "limit": config::MAX_STACK,
939 }),
940 ],
941 );
942 }
943
944 #[test]
945 fn exceed_stack_limit_with_non_modified_patch() {
946 let (ctx, _) = repo_utils::prepare_repo();
949 let parent_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
950 repo_utils::empty_commit_chain(&ctx.repo, "HEAD", &[&parent_commit], config::MAX_STACK);
951 let a_new_file_path = PathBuf::from("a_whole_new_file.txt");
952 std::fs::write(ctx.join(&a_new_file_path), "contents").unwrap();
953 repo_utils::stage_file_changes(&ctx, &a_new_file_path);
954 let another_new_file_path = PathBuf::from("another_whole_new_file.txt");
955 std::fs::write(ctx.join(&another_new_file_path), "contents").unwrap();
956 repo_utils::stage_file_changes(&ctx, &another_new_file_path);
957
958 let mut capturing_logger = log_utils::CapturingLogger::new();
960 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
961
962 let mut revwalk = ctx.repo.revwalk().unwrap();
963 revwalk.push_head().unwrap();
964 assert_eq!(
965 revwalk.count(),
966 config::MAX_STACK + 1,
967 "Wrong number of commits."
968 );
969
970 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
971 assert!(is_something_in_index);
972
973 log_utils::assert_log_messages_are(
974 capturing_logger.visible_logs(),
975 vec![&json!({
976 "level": "WARN",
977 "msg": "No changes were in-place file modifications. \
978 Added, removed, or renamed files cannot be automatically absorbed.",
979 })],
980 );
981 }
982
983 #[test]
984 fn exceed_stack_limit_with_modified_patch_and_non_modified_hunks() {
985 let (ctx, file_path) = repo_utils::prepare_repo();
989 let new_file_path = PathBuf::from("a_whole_new_file.txt");
990 let parent_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
991 repo_utils::empty_commit_chain(&ctx.repo, "HEAD", &[&parent_commit], config::MAX_STACK);
992 std::fs::write(ctx.join(&new_file_path), "contents").unwrap();
993 repo_utils::stage_file_changes(&ctx, &new_file_path);
994 repo_utils::stage_file_changes(&ctx, &file_path);
995
996 let mut capturing_logger = log_utils::CapturingLogger::new();
998 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
999
1000 let mut revwalk = ctx.repo.revwalk().unwrap();
1001 revwalk.push_head().unwrap();
1002 assert_eq!(
1003 revwalk.count(),
1004 config::MAX_STACK + 1,
1005 "Wrong number of commits."
1006 );
1007
1008 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1009 assert!(is_something_in_index);
1010
1011 log_utils::assert_log_messages_are(
1012 capturing_logger.visible_logs(),
1013 vec![
1014 &json!({
1015 "level": "WARN",
1016 "msg": "Some changes were not in-place file modifications. \
1017 Added, removed, or renamed files cannot be automatically absorbed.",
1018 }),
1019 &json!({
1020 "level": "WARN",
1021 "msg": "Some file modifications did not have an available commit to fix up. \
1022 You will have to manually create fixup commits.",
1023 }),
1024 &json!({
1025 "level": "WARN",
1026 "msg": format!(
1027 "Will not fix up past maximum stack limit. \
1028 Use --base or configure {} to override",
1029 config::MAX_STACK_CONFIG_NAME
1030 ),
1031 }),
1032 ],
1033 );
1034 }
1035
1036 #[test]
1037 fn no_stack_limit_exceeds_stack_limit() {
1038 let (ctx, initial_fp) = repo_utils::prepare_repo();
1039 let parent_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
1040 repo_utils::empty_commit_chain(&ctx.repo, "HEAD", &[&parent_commit], config::MAX_STACK);
1041
1042 repo_utils::stage_file_changes(&ctx, &initial_fp);
1043
1044 let config = Config {
1045 no_limit: true,
1046 one_fixup_per_commit: true,
1048 ..DEFAULT_CONFIG
1049 };
1050
1051 let capturing_logger = log_utils::CapturingLogger::new();
1053 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1054
1055 let mut revwalk = ctx.repo.revwalk().unwrap();
1056 revwalk.push_head().unwrap();
1057
1058 assert_eq!(
1059 revwalk.count(),
1060 config::MAX_STACK + 2,
1062 "Wrong number of commits."
1063 );
1064
1065 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1066 }
1067
1068 #[test]
1069 fn reached_root() {
1070 let (ctx, _) = repo_utils::prepare_repo();
1071 let file_path = PathBuf::from("a_whole_new_file.txt");
1072 std::fs::write(ctx.join(&file_path), "contents").unwrap();
1073 repo_utils::stage_file_changes(&ctx, &file_path);
1074
1075 let mut capturing_logger = log_utils::CapturingLogger::new();
1077 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1078
1079 let mut revwalk = ctx.repo.revwalk().unwrap();
1080 revwalk.push_head().unwrap();
1081 assert_eq!(revwalk.count(), 1, "Wrong number of commits.");
1082
1083 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1084 assert!(is_something_in_index);
1085
1086 log_utils::assert_log_messages_are(
1087 capturing_logger.visible_logs(),
1088 vec![&json!({
1089 "level": "WARN",
1090 "msg": "No changes were in-place file modifications. \
1091 Added, removed, or renamed files cannot be automatically absorbed."
1092 })],
1093 );
1094 }
1095
1096 #[test]
1097 fn user_defined_base_hides_target_commit() {
1098 let ctx = repo_utils::prepare_and_stage();
1099
1100 let mut capturing_logger = log_utils::CapturingLogger::new();
1102 let config = Config {
1103 base: Some("HEAD"),
1104 ..DEFAULT_CONFIG
1105 };
1106 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1107
1108 let mut revwalk = ctx.repo.revwalk().unwrap();
1109 revwalk.push_head().unwrap();
1110 assert_eq!(revwalk.count(), 1, "Wrong number of commits.");
1111
1112 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1113 assert!(is_something_in_index);
1114
1115 log_utils::assert_log_messages_are(
1116 capturing_logger.visible_logs(),
1117 vec![
1118 &json!({
1119 "level": "WARN",
1120 "msg": "Some file modifications did not have an available commit to fix up. \
1121 You will have to manually create fixup commits.",
1122 }),
1123 &json!({
1124 "level": "WARN",
1125 "msg": "Will not fix up past specified base commit. \
1126 Consider using --base to specify a different base commit",
1127 "base": "HEAD",
1128 }),
1129 ],
1130 );
1131 }
1132
1133 #[test]
1134 fn merge_commit_found() {
1135 let (ctx, file_path) = repo_utils::prepare_repo();
1136 repo_utils::merge_commit(
1137 &ctx.repo,
1138 &[&ctx.repo.head().unwrap().peel_to_commit().unwrap()],
1139 );
1140 repo_utils::stage_file_changes(&ctx, &file_path);
1141
1142 let mut capturing_logger = log_utils::CapturingLogger::new();
1144 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1145
1146 let mut revwalk = ctx.repo.revwalk().unwrap();
1147 revwalk.push_head().unwrap();
1148 assert_eq!(revwalk.count(), 4, "Wrong number of commits.");
1149
1150 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1151 assert!(is_something_in_index);
1152
1153 log_utils::assert_log_messages_are(
1154 capturing_logger.visible_logs(),
1155 vec![
1156 &json!({
1157 "level": "WARN",
1158 "msg": "Some file modifications did not have an available commit to fix up. \
1159 You will have to manually create fixup commits.",
1160 }),
1161 &json!({
1162 "level": "WARN",
1163 "msg": "Cannot fix up past a merge commit",
1164 }),
1165 ],
1166 );
1167 }
1168
1169 #[test]
1170 fn merge_commit_before_target_commit() {
1171 let (ctx, file_path) = repo_utils::prepare_repo();
1172 let merge_commit = repo_utils::merge_commit(
1173 &ctx.repo,
1174 &[&ctx.repo.head().unwrap().peel_to_commit().unwrap()],
1175 );
1176
1177 std::fs::write(&ctx.join(&file_path), "new content").unwrap();
1178 let tree = repo_utils::add(&ctx.repo, &file_path);
1179 repo_utils::commit(
1180 &ctx.repo,
1181 "HEAD",
1182 "Change after merge",
1183 &tree,
1184 &[&merge_commit],
1185 );
1186
1187 repo_utils::stage_file_changes(&ctx, &file_path);
1188
1189 let mut capturing_logger = log_utils::CapturingLogger::new();
1191 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1192
1193 let mut revwalk = ctx.repo.revwalk().unwrap();
1194 revwalk.push_head().unwrap();
1195 assert_eq!(revwalk.count(), 6, "Wrong number of commits.");
1196
1197 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1198
1199 log_utils::assert_log_messages_are(
1200 capturing_logger.visible_logs(),
1201 vec![
1202 &json!({"level": "INFO", "msg": "committed",}),
1203 &json!({
1204 "level": "INFO",
1205 "msg": "To squash the new commits, rebase:",
1206 "command": format!(
1207 "git rebase --interactive --autosquash --autostash {}",
1208 merge_commit.id()),
1209 }),
1210 ],
1211 );
1212 }
1213
1214 #[test]
1215 fn first_hidden_commit_is_merge() {
1216 let (ctx, file_path) = repo_utils::prepare_repo();
1217 let merge_commit = repo_utils::merge_commit(
1218 &ctx.repo,
1219 &[&ctx.repo.head().unwrap().peel_to_commit().unwrap()],
1220 );
1221 repo_utils::empty_commit(&ctx.repo, "HEAD", "empty commit", &[&merge_commit]);
1222 repo_utils::stage_file_changes(&ctx, &file_path);
1223
1224 let mut capturing_logger = log_utils::CapturingLogger::new();
1226 let base_id = merge_commit.id().to_string();
1227 let config = Config {
1228 base: Some(&base_id),
1229 ..DEFAULT_CONFIG
1230 };
1231 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1232
1233 let mut revwalk = ctx.repo.revwalk().unwrap();
1234 revwalk.push_head().unwrap();
1235 assert_eq!(revwalk.count(), 5, "Wrong number of commits.");
1236
1237 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1238 assert!(is_something_in_index);
1239
1240 log_utils::assert_log_messages_are(
1241 capturing_logger.visible_logs(),
1242 vec![
1243 &json!({
1244 "level": "WARN",
1245 "msg": "Some file modifications did not have an available commit to fix up. \
1246 You will have to manually create fixup commits.",
1247 }),
1248 &json!({
1249 "level": "WARN",
1250 "msg": "Cannot fix up past a merge commit",
1251 }),
1252 ],
1253 );
1254 }
1255
1256 #[test]
1257 fn first_hidden_commit_is_by_another_author() {
1258 let (ctx, file_path) = repo_utils::prepare_repo();
1259 let first_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
1260 ctx.repo
1261 .branch("some-branch", &first_commit, false)
1262 .unwrap();
1263 repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com");
1264 repo_utils::empty_commit(&ctx.repo, "HEAD", "empty commit", &[&first_commit]);
1265 repo_utils::stage_file_changes(&ctx, &file_path);
1266
1267 let mut capturing_logger = log_utils::CapturingLogger::new();
1269 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1270
1271 let mut revwalk = ctx.repo.revwalk().unwrap();
1272 revwalk.push_head().unwrap();
1273 assert_eq!(revwalk.count(), 2, "Wrong number of commits.");
1274
1275 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1276 assert!(is_something_in_index);
1277
1278 log_utils::assert_log_messages_are(
1279 capturing_logger.visible_logs(),
1280 vec![
1281 &json!({
1282 "level": "WARN",
1283 "msg": "Some file modifications did not have an available commit to fix up. \
1284 You will have to manually create fixup commits.",
1285 }),
1286 &json!({
1287 "level": "WARN",
1288 "msg": "Will not fix up past commits by another author. \
1289 Use --force-author to override",
1290 }),
1291 ],
1292 );
1293 }
1294
1295 #[test]
1296 fn first_hidden_commit_is_regular_commit() {
1297 let (ctx, file_path) = repo_utils::prepare_repo();
1298 let first_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
1299 ctx.repo
1300 .branch("some-branch", &first_commit, false)
1301 .unwrap();
1302 repo_utils::empty_commit(&ctx.repo, "HEAD", "empty commit", &[&first_commit]);
1303 repo_utils::stage_file_changes(&ctx, &file_path);
1304
1305 let mut capturing_logger = log_utils::CapturingLogger::new();
1307 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1308
1309 let mut revwalk = ctx.repo.revwalk().unwrap();
1310 revwalk.push_head().unwrap();
1311 assert_eq!(revwalk.count(), 2, "Wrong number of commits.");
1312
1313 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1314 assert!(is_something_in_index);
1315
1316 log_utils::assert_log_messages_are(
1317 capturing_logger.visible_logs(),
1318 vec![
1319 &json!({
1320 "level": "WARN",
1321 "msg": "Some file modifications did not have an available commit to fix up. \
1322 You will have to manually create fixup commits.",
1323 }),
1324 &json!({
1325 "level": "WARN",
1326 "msg": "Will not fix up commits reachable by other branches. \
1327 Use --base to specify a base commit.",
1328 }),
1329 ],
1330 );
1331 }
1332
1333 #[test]
1334 fn one_fixup_per_commit() {
1335 let ctx = repo_utils::prepare_and_stage();
1336
1337 let mut capturing_logger = log_utils::CapturingLogger::new();
1339 let config = Config {
1340 one_fixup_per_commit: true,
1341 ..DEFAULT_CONFIG
1342 };
1343 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1344
1345 let mut revwalk = ctx.repo.revwalk().unwrap();
1346 revwalk.push_head().unwrap();
1347 assert_eq!(revwalk.count(), 2);
1348
1349 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1350
1351 log_utils::assert_log_messages_are(
1352 capturing_logger.visible_logs(),
1353 vec![
1354 &json!({
1355 "level": "INFO",
1356 "msg": "committed",
1357 "fixup": "Initial commit.",
1358 "header": "3 insertions(+)",
1359 }),
1360 &json!({
1361 "level": "INFO",
1362 "msg": "To squash the new commits, rebase:",
1363 "command": "git rebase --interactive --autosquash --autostash --root",
1364 }),
1365 ],
1366 );
1367 }
1368
1369 #[test]
1370 fn another_author() {
1371 let ctx = repo_utils::prepare_and_stage();
1372
1373 repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com");
1374
1375 let mut capturing_logger = log_utils::CapturingLogger::new();
1377 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1378
1379 let mut revwalk = ctx.repo.revwalk().unwrap();
1380 revwalk.push_head().unwrap();
1381 assert_eq!(revwalk.count(), 1);
1382 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1383 assert!(is_something_in_index);
1384
1385 log_utils::assert_log_messages_are(
1386 capturing_logger.visible_logs(),
1387 vec![
1388 &json!({
1389 "level": "WARN",
1390 "msg": "Some file modifications did not have an available commit to fix up. \
1391 You will have to manually create fixup commits.",
1392 }),
1393 &json!({
1394 "level": "WARN",
1395 "msg": "Will not fix up past commits by another author. \
1396 Use --force-author to override"
1397 }),
1398 ],
1399 );
1400 }
1401
1402 #[test]
1403 fn another_author_with_force_author_flag() {
1404 let ctx = repo_utils::prepare_and_stage();
1405
1406 repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com");
1407
1408 let mut capturing_logger = log_utils::CapturingLogger::new();
1410 let config = Config {
1411 force_author: true,
1412 ..DEFAULT_CONFIG
1413 };
1414 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1415
1416 let mut revwalk = ctx.repo.revwalk().unwrap();
1417 revwalk.push_head().unwrap();
1418 assert_eq!(revwalk.count(), 3);
1419
1420 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1421
1422 log_utils::assert_log_messages_are(
1423 capturing_logger.visible_logs(),
1424 vec![
1425 &json!({"level": "INFO", "msg": "committed"}),
1426 &json!({"level": "INFO", "msg": "committed"}),
1427 &json!({
1428 "level": "INFO",
1429 "msg": "To squash the new commits, rebase:",
1430 "command": "git rebase --interactive --autosquash --autostash --root",
1431 }),
1432 ],
1433 );
1434 }
1435
1436 #[test]
1437 fn another_author_with_force_author_config() {
1438 let ctx = repo_utils::prepare_and_stage();
1439
1440 repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com");
1441
1442 repo_utils::set_config_flag(&ctx.repo, "absorb.forceAuthor");
1443
1444 let mut capturing_logger = log_utils::CapturingLogger::new();
1446 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1447
1448 let mut revwalk = ctx.repo.revwalk().unwrap();
1449 revwalk.push_head().unwrap();
1450 assert_eq!(revwalk.count(), 3);
1451
1452 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1453
1454 log_utils::assert_log_messages_are(
1455 capturing_logger.visible_logs(),
1456 vec![
1457 &json!({"level": "INFO", "msg": "committed"}),
1458 &json!({"level": "INFO", "msg": "committed"}),
1459 &json!({
1460 "level": "INFO",
1461 "msg": "To squash the new commits, rebase:",
1462 "command": "git rebase --interactive --autosquash --autostash --root",
1463 }),
1464 ],
1465 );
1466 }
1467
1468 #[test]
1469 fn detached_head() {
1470 let ctx = repo_utils::prepare_and_stage();
1471 repo_utils::detach_head(&ctx.repo);
1472
1473 let capturing_logger = log_utils::CapturingLogger::new();
1475 let result = run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo);
1476 assert_eq!(
1477 result.err().unwrap().to_string(),
1478 "HEAD is not a branch, use --force-detach to override"
1479 );
1480
1481 let mut revwalk = ctx.repo.revwalk().unwrap();
1482 revwalk.push_head().unwrap();
1483 assert_eq!(revwalk.count(), 1);
1484 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1485 assert!(is_something_in_index);
1486 }
1487
1488 #[test]
1489 fn detached_head_pointing_at_branch_with_force_detach_flag() {
1490 let ctx = repo_utils::prepare_and_stage();
1491 repo_utils::detach_head(&ctx.repo);
1492
1493 let mut capturing_logger = log_utils::CapturingLogger::new();
1495 let config = Config {
1496 force_detach: true,
1497 ..DEFAULT_CONFIG
1498 };
1499 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1500 let mut revwalk = ctx.repo.revwalk().unwrap();
1501 revwalk.push_head().unwrap();
1502
1503 assert_eq!(revwalk.count(), 1); let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1505 assert!(is_something_in_index);
1506
1507 log_utils::assert_log_messages_are(
1508 capturing_logger.visible_logs(),
1509 vec![
1510 &json!({
1511 "level": "WARN",
1512 "msg": "HEAD is not a branch, but --force-detach used to continue."}),
1513 &json!({
1514 "level": "WARN",
1515 "msg": "Some file modifications did not have an available commit to fix up. \
1516 You will have to manually create fixup commits.",
1517 }),
1518 &json!({
1519 "level": "WARN",
1520 "msg": "Will not fix up commits reachable by other branches. \
1521 Use --base to specify a base commit."
1522 }),
1523 ],
1524 );
1525 }
1526
1527 #[test]
1528 fn detached_head_with_force_detach_flag() {
1529 let ctx = repo_utils::prepare_and_stage();
1530 repo_utils::detach_head(&ctx.repo);
1531 repo_utils::delete_branch(&ctx.repo, "master");
1532
1533 let mut capturing_logger = log_utils::CapturingLogger::new();
1535 let config = Config {
1536 force_detach: true,
1537 ..DEFAULT_CONFIG
1538 };
1539 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1540 let mut revwalk = ctx.repo.revwalk().unwrap();
1541 revwalk.push_head().unwrap();
1542
1543 assert_eq!(revwalk.count(), 3);
1544 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1545
1546 log_utils::assert_log_messages_are(
1547 capturing_logger.visible_logs(),
1548 vec![
1549 &json!({
1550 "level": "WARN",
1551 "msg": "HEAD is not a branch, but --force-detach used to continue.",
1552 }),
1553 &json!({"level": "INFO", "msg": "committed"}),
1554 &json!({"level": "INFO", "msg": "committed"}),
1555 &json!({
1556 "level": "INFO",
1557 "msg": "To squash the new commits, rebase:",
1558 "command": "git rebase --interactive --autosquash --autostash --root",
1559 }),
1560 ],
1561 );
1562 }
1563
1564 #[test]
1565 fn detached_head_with_force_detach_config() {
1566 let ctx = repo_utils::prepare_and_stage();
1567 repo_utils::detach_head(&ctx.repo);
1568 repo_utils::delete_branch(&ctx.repo, "master");
1569
1570 repo_utils::set_config_flag(&ctx.repo, "absorb.forceDetach");
1571
1572 let mut capturing_logger = log_utils::CapturingLogger::new();
1574 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1575 let mut revwalk = ctx.repo.revwalk().unwrap();
1576 revwalk.push_head().unwrap();
1577
1578 assert_eq!(revwalk.count(), 3);
1579 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1580
1581 log_utils::assert_log_messages_are(
1582 capturing_logger.visible_logs(),
1583 vec![
1584 &json!({
1585 "level": "WARN",
1586 "msg": "HEAD is not a branch, but --force-detach used to continue.",
1587 }),
1588 &json!({"level": "INFO", "msg": "committed"}),
1589 &json!({"level": "INFO", "msg": "committed"}),
1590 &json!({
1591 "level": "INFO",
1592 "msg": "To squash the new commits, rebase:",
1593 "command": "git rebase --interactive --autosquash --autostash --root",
1594 }),
1595 ],
1596 );
1597 }
1598
1599 #[test]
1600 fn and_rebase_flag() {
1601 let ctx = repo_utils::prepare_and_stage();
1602 repo_utils::set_config_option(&ctx.repo, "core.editor", "true");
1603 repo_utils::set_config_option(&ctx.repo, "advice.waitingForEditor", "false");
1604
1605 let mut capturing_logger = log_utils::CapturingLogger::new();
1607 let config = Config {
1608 and_rebase: true,
1609 ..DEFAULT_CONFIG
1610 };
1611 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1612
1613 let mut revwalk = ctx.repo.revwalk().unwrap();
1614 revwalk.push_head().unwrap();
1615
1616 assert_eq!(revwalk.count(), 1);
1617 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1618
1619 log_utils::assert_log_messages_are(
1620 capturing_logger.visible_logs(),
1621 vec![
1622 &json!({"level": "INFO", "msg": "committed"}),
1623 &json!({"level": "INFO", "msg": "committed"}),
1624 ],
1625 );
1626 }
1627
1628 #[test]
1629 fn and_rebase_flag_with_rebase_options() {
1630 let ctx = repo_utils::prepare_and_stage();
1631 repo_utils::set_config_option(&ctx.repo, "core.editor", "true");
1632 repo_utils::set_config_option(&ctx.repo, "advice.waitingForEditor", "false");
1633
1634 let mut capturing_logger = log_utils::CapturingLogger::new();
1636 let config = Config {
1637 and_rebase: true,
1638 rebase_options: &vec!["--signoff"],
1639 ..DEFAULT_CONFIG
1640 };
1641 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1642
1643 let mut revwalk = ctx.repo.revwalk().unwrap();
1644 revwalk.push_head().unwrap();
1645 assert_eq!(revwalk.count(), 1);
1646
1647 let trailers = message_trailers_strs(
1648 ctx.repo
1649 .head()
1650 .unwrap()
1651 .peel_to_commit()
1652 .unwrap()
1653 .message()
1654 .unwrap(),
1655 )
1656 .unwrap();
1657 assert_eq!(
1658 trailers
1659 .iter()
1660 .filter(|trailer| trailer.0 == "Signed-off-by")
1661 .count(),
1662 1
1663 );
1664
1665 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1666
1667 log_utils::assert_log_messages_are(
1668 capturing_logger.visible_logs(),
1669 vec![
1670 &json!({"level": "INFO", "msg": "committed"}),
1671 &json!({"level": "INFO", "msg": "committed"}),
1672 ],
1673 );
1674 }
1675
1676 #[test]
1677 fn rebase_options_without_and_rebase_flag() {
1678 let ctx = repo_utils::prepare_and_stage();
1679
1680 let capturing_logger = log_utils::CapturingLogger::new();
1682 let config = Config {
1683 rebase_options: &vec!["--some-option"],
1684 ..DEFAULT_CONFIG
1685 };
1686 let result = run_with_repo(&capturing_logger.logger, &config, &ctx.repo);
1687
1688 assert_eq!(
1689 result.err().unwrap().to_string(),
1690 "REBASE_OPTIONS were specified without --and-rebase flag"
1691 );
1692
1693 let mut revwalk = ctx.repo.revwalk().unwrap();
1694 revwalk.push_head().unwrap();
1695 assert_eq!(revwalk.count(), 1);
1696 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1697 assert!(is_something_in_index);
1698 }
1699
1700 #[test]
1701 fn squash_flag() {
1702 let ctx = repo_utils::prepare_and_stage();
1703
1704 let mut capturing_logger = log_utils::CapturingLogger::new();
1706 let config = Config {
1707 squash: true,
1708 ..DEFAULT_CONFIG
1709 };
1710 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1711
1712 assert_eq!(
1713 extract_commit_messages(&ctx.repo),
1714 vec![
1715 "squash! Initial commit.\n",
1716 "squash! Initial commit.\n",
1717 "Initial commit.",
1718 ]
1719 );
1720
1721 log_utils::assert_log_messages_are(
1722 capturing_logger.visible_logs(),
1723 vec![
1724 &json!({"level": "INFO", "msg": "committed"}),
1725 &json!({"level": "INFO", "msg": "committed"}),
1726 &json!({
1727 "level": "INFO",
1728 "msg": "To squash the new commits, rebase:",
1729 "command": "git rebase --interactive --autosquash --autostash --root",
1730 }),
1731 ],
1732 );
1733 }
1734
1735 #[test]
1736 fn run_with_squash_config_option() {
1737 let ctx = repo_utils::prepare_and_stage();
1738
1739 repo_utils::set_config_flag(&ctx.repo, "absorb.createSquashCommits");
1740
1741 let mut capturing_logger = log_utils::CapturingLogger::new();
1743 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1744
1745 assert_eq!(
1746 extract_commit_messages(&ctx.repo),
1747 vec![
1748 "squash! Initial commit.\n",
1749 "squash! Initial commit.\n",
1750 "Initial commit.",
1751 ]
1752 );
1753
1754 log_utils::assert_log_messages_are(
1755 capturing_logger.visible_logs(),
1756 vec![
1757 &json!({"level": "INFO", "msg": "committed"}),
1758 &json!({"level": "INFO", "msg": "committed"}),
1759 &json!({
1760 "level": "INFO",
1761 "msg": "To squash the new commits, rebase:",
1762 "command": "git rebase --interactive --autosquash --autostash --root",
1763 }),
1764 ],
1765 );
1766 }
1767
1768 #[test]
1769 fn dry_run_flag() {
1770 let ctx = repo_utils::prepare_and_stage();
1771
1772 let mut capturing_logger = log_utils::CapturingLogger::new();
1774 let config = Config {
1775 dry_run: true,
1776 ..DEFAULT_CONFIG
1777 };
1778 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1779
1780 let mut revwalk = ctx.repo.revwalk().unwrap();
1781 revwalk.push_head().unwrap();
1782 assert_eq!(revwalk.count(), 1);
1783 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1784 assert!(is_something_in_index);
1785
1786 let pre_absorb_ref_commit = ctx.repo.references_glob("PRE_ABSORB_HEAD").unwrap().last();
1787 assert!(pre_absorb_ref_commit.is_none());
1788
1789 log_utils::assert_log_messages_are(
1790 capturing_logger.visible_logs(),
1791 vec![
1792 &json!({
1793 "level": "INFO",
1794 "msg": "would have committed",
1795 "fixup": "Initial commit.",
1796 "header": "1 insertion(+)",
1797 }),
1798 &json!({
1799 "level": "INFO",
1800 "msg": "would have committed",
1801 "fixup": "Initial commit.",
1802 "header": "2 insertions(+)",
1803 }),
1804 ],
1805 );
1806 }
1807
1808 #[test]
1809 fn dry_run_flag_with_and_rebase_flag() {
1810 let (ctx, path) = repo_utils::prepare_repo();
1811 repo_utils::set_config_option(&ctx.repo, "core.editor", "true");
1812
1813 let tree = repo_utils::stage_file_changes(&ctx, &path);
1815 let head_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
1816 let fixup_message = format!("fixup! {}\n", head_commit.id());
1817 repo_utils::commit(&ctx.repo, "HEAD", &fixup_message, &tree, &[&head_commit]);
1818
1819 repo_utils::stage_file_changes(&ctx, &path);
1821
1822 let mut capturing_logger = log_utils::CapturingLogger::new();
1824 let config = Config {
1825 and_rebase: true,
1826 dry_run: true,
1827 ..DEFAULT_CONFIG
1828 };
1829 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1830
1831 let mut revwalk = ctx.repo.revwalk().unwrap();
1832 revwalk.push_head().unwrap();
1833 assert_eq!(revwalk.count(), 2); let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1835 assert!(is_something_in_index);
1836
1837 log_utils::assert_log_messages_are(
1838 capturing_logger.visible_logs(),
1839 vec![
1840 &json!({"level": "INFO", "msg": "would have committed",}),
1841 &json!({"level": "INFO", "msg": "would have committed",}),
1842 &json!({"level": "INFO", "msg": "would have run git rebase",}),
1843 ],
1844 );
1845 }
1846
1847 fn autostage_common(ctx: &repo_utils::Context, file_path: &PathBuf) -> (PathBuf, PathBuf) {
1848 let path = ctx.join(file_path);
1850 let contents = std::fs::read_to_string(&path).unwrap();
1851 let modifications = format!("{contents}\nnew_line2");
1852 std::fs::write(&path, &modifications).unwrap();
1853
1854 let fp2 = PathBuf::from("unrel.txt");
1856 std::fs::write(ctx.join(&fp2), "foo").unwrap();
1857
1858 (path, fp2)
1859 }
1860
1861 #[test]
1862 fn autostage_if_index_was_empty() {
1863 let (ctx, file_path) = repo_utils::prepare_repo();
1864
1865 ctx.repo
1867 .config()
1868 .unwrap()
1869 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
1870 .unwrap();
1871
1872 autostage_common(&ctx, &file_path);
1873
1874 let mut capturing_logger = log_utils::CapturingLogger::new();
1876 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1877
1878 let mut revwalk = ctx.repo.revwalk().unwrap();
1879 revwalk.push_head().unwrap();
1880 assert_eq!(revwalk.count(), 2);
1881
1882 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1883
1884 log_utils::assert_log_messages_are(
1885 capturing_logger.visible_logs(),
1886 vec![
1887 &json!({"level": "INFO", "msg": "committed"}),
1888 &json!({
1889 "level": "INFO",
1890 "msg": "To squash the new commits, rebase:",
1891 "command": "git rebase --interactive --autosquash --autostash --root",
1892 }),
1893 ],
1894 );
1895 }
1896
1897 #[test]
1898 fn do_not_autostage_if_index_was_not_empty() {
1899 let (ctx, file_path) = repo_utils::prepare_repo();
1900
1901 ctx.repo
1903 .config()
1904 .unwrap()
1905 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
1906 .unwrap();
1907
1908 let (_, fp2) = autostage_common(&ctx, &file_path);
1909 repo_utils::add(&ctx.repo, &fp2);
1911
1912 let mut capturing_logger = log_utils::CapturingLogger::new();
1914 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1915
1916 let mut revwalk = ctx.repo.revwalk().unwrap();
1917 revwalk.push_head().unwrap();
1918 assert_eq!(revwalk.count(), 1);
1919
1920 assert_eq!(index_stats(&ctx.repo).unwrap().files_changed(), 1);
1921
1922 log_utils::assert_log_messages_are(
1923 capturing_logger.visible_logs(),
1924 vec![&json!({
1925 "level": "WARN",
1926 "msg": "No changes were in-place file modifications. \
1927 Added, removed, or renamed files cannot be automatically absorbed."
1928 })],
1929 );
1930 }
1931
1932 #[test]
1933 fn do_not_autostage_if_not_enabled_by_config_var() {
1934 let (ctx, file_path) = repo_utils::prepare_repo();
1935
1936 ctx.repo
1938 .config()
1939 .unwrap()
1940 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, false)
1941 .unwrap();
1942
1943 autostage_common(&ctx, &file_path);
1944
1945 let mut capturing_logger = log_utils::CapturingLogger::new();
1947 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1948
1949 let mut revwalk = ctx.repo.revwalk().unwrap();
1950 revwalk.push_head().unwrap();
1951 assert_eq!(revwalk.count(), 1);
1952
1953 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1954
1955 log_utils::assert_log_messages_are(
1956 capturing_logger.visible_logs(),
1957 vec![&json!({
1958 "level": "WARN",
1959 "msg": format!(
1960 "No changes staged. \
1961 Try adding something to the index or set {} = true.",
1962 config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME,
1963 ),
1964 })],
1965 );
1966 }
1967
1968 #[test]
1969 fn autostage_if_index_was_empty_and_no_changes() {
1970 let (ctx, _file_path) = repo_utils::prepare_repo();
1971
1972 ctx.repo
1974 .config()
1975 .unwrap()
1976 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
1977 .unwrap();
1978
1979 let mut capturing_logger = log_utils::CapturingLogger::new();
1981 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1982
1983 let mut revwalk = ctx.repo.revwalk().unwrap();
1984 revwalk.push_head().unwrap();
1985 assert_eq!(revwalk.count(), 1);
1986
1987 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1988
1989 log_utils::assert_log_messages_are(
1990 capturing_logger.visible_logs(),
1991 vec![&json!({
1992 "level": "WARN",
1993 "msg": "No changes staged, even after auto-staging. \
1994 Try adding something to the index."})],
1995 );
1996 }
1997
1998 #[test]
1999 fn fixup_message_always_commit_sha_if_configured() {
2000 let ctx = repo_utils::prepare_and_stage();
2001
2002 ctx.repo
2003 .config()
2004 .unwrap()
2005 .set_bool(config::FIXUP_TARGET_ALWAYS_SHA_CONFIG_NAME, true)
2006 .unwrap();
2007
2008 let mut capturing_logger = log_utils::CapturingLogger::new();
2010 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
2011 assert!(nothing_left_in_index(&ctx.repo).unwrap());
2012
2013 let mut revwalk = ctx.repo.revwalk().unwrap();
2014 revwalk.push_head().unwrap();
2015
2016 let oids: Vec<git2::Oid> = revwalk.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
2017 assert_eq!(oids.len(), 3);
2018
2019 let commit = ctx.repo.find_commit(oids[0]).unwrap();
2020 let actual_msg = commit.summary().unwrap();
2021 let expected_msg = format!("fixup! {}", oids.last().unwrap());
2022 assert_eq!(actual_msg, expected_msg);
2023
2024 log_utils::assert_log_messages_are(
2025 capturing_logger.visible_logs(),
2026 vec![
2027 &json!({"level": "INFO", "msg": "committed"}),
2028 &json!({"level": "INFO", "msg": "committed"}),
2029 &json!({
2030 "level": "INFO",
2031 "msg": "To squash the new commits, rebase:",
2032 "command": "git rebase --interactive --autosquash --autostash --root",
2033 }),
2034 ],
2035 );
2036 }
2037
2038 #[test]
2039 fn fixup_message_option_left_out_sets_only_summary() {
2040 let ctx = repo_utils::prepare_and_stage();
2041
2042 let drain = slog::Discard;
2044 let logger = slog::Logger::root(drain, o!());
2045 run_with_repo(&logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
2046 assert!(nothing_left_in_index(&ctx.repo).unwrap());
2047
2048 let mut revwalk = ctx.repo.revwalk().unwrap();
2049 revwalk.push_head().unwrap();
2050
2051 let oids: Vec<git2::Oid> = revwalk.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
2052 assert_eq!(oids.len(), 3);
2053
2054 let fixup_commit = ctx.repo.find_commit(oids[0]).unwrap();
2055 let fixed_up_commit = ctx.repo.find_commit(*oids.last().unwrap()).unwrap();
2056 let actual_msg = fixup_commit.message().unwrap();
2057 let expected_msg = fixed_up_commit.message().unwrap();
2058 let expected_msg = format!("fixup! {}\n", expected_msg);
2059 assert_eq!(actual_msg, expected_msg);
2060 }
2061
2062 #[test]
2063 fn fixup_message_option_provided_sets_message() {
2064 let ctx = repo_utils::prepare_and_stage();
2065
2066 let drain = slog::Discard;
2068 let logger = slog::Logger::root(drain, o!());
2069 let fixup_message_body = "git-absorb is my favorite git tool!";
2070 let config = Config {
2071 message: Some(fixup_message_body),
2072 ..DEFAULT_CONFIG
2073 };
2074 run_with_repo(&logger, &config, &ctx.repo).unwrap();
2075 assert!(nothing_left_in_index(&ctx.repo).unwrap());
2076
2077 let mut revwalk = ctx.repo.revwalk().unwrap();
2078 revwalk.push_head().unwrap();
2079
2080 let oids: Vec<git2::Oid> = revwalk.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
2081 assert_eq!(oids.len(), 3);
2082
2083 let fixup_commit = ctx.repo.find_commit(oids[0]).unwrap();
2084 let fixed_up_commit = ctx.repo.find_commit(*oids.last().unwrap()).unwrap();
2085 let actual_msg = fixup_commit.message().unwrap();
2086 let expected_msg = fixed_up_commit.message().unwrap();
2087 let expected_msg = format!("fixup! {}\n\n{}\n", expected_msg, fixup_message_body);
2088 assert_eq!(actual_msg, expected_msg);
2089 }
2090
2091 fn extract_commit_messages(repo: &git2::Repository) -> Vec<String> {
2093 let mut revwalk = repo.revwalk().unwrap();
2094 revwalk.push_head().unwrap();
2095
2096 let mut messages = Vec::new();
2097
2098 for oid in revwalk {
2099 let commit = repo.find_commit(oid.unwrap()).unwrap();
2100 if let Some(message) = commit.message() {
2101 messages.push(message.to_string());
2102 }
2103 }
2104
2105 messages
2106 }
2107
2108 const DEFAULT_CONFIG: Config = Config {
2109 dry_run: false,
2110 no_limit: false,
2111 force_author: false,
2112 force_detach: false,
2113 base: None,
2114 and_rebase: false,
2115 rebase_options: &Vec::new(),
2116 whole_file: false,
2117 one_fixup_per_commit: false,
2118 squash: false,
2119 message: None,
2120 };
2121}