1#[macro_use]
2extern crate slog;
3use anyhow::{anyhow, Result};
4
5mod commute;
6mod config;
7mod owned;
8mod stack;
9
10use std::io::Write;
11use std::path::Path;
12
13pub struct Config<'a> {
14 pub dry_run: bool,
15 pub force_author: bool,
16 pub force_detach: bool,
17 pub base: Option<&'a str>,
18 pub and_rebase: bool,
19 pub rebase_options: &'a Vec<&'a str>,
20 pub whole_file: bool,
21 pub one_fixup_per_commit: bool,
22 pub message: Option<&'a str>,
23}
24
25pub fn run(logger: &slog::Logger, config: &Config) -> Result<()> {
26 let repo = git2::Repository::open_from_env()?;
27 debug!(logger, "repository found"; "path" => repo.path().to_str());
28
29 run_with_repo(logger, config, &repo)
30}
31
32fn run_with_repo(logger: &slog::Logger, config: &Config, repo: &git2::Repository) -> Result<()> {
33 let config = config::unify(config, repo);
34
35 if !config.rebase_options.is_empty() && !config.and_rebase {
36 return Err(anyhow!(
37 "REBASE_OPTIONS were specified without --and-rebase flag"
38 ));
39 }
40
41 let mut we_added_everything_to_index = false;
42 if nothing_left_in_index(repo)? {
43 if config::auto_stage_if_nothing_staged(repo) {
44 let pathspec = ["."];
47 let mut index = repo.index()?;
48 index.add_all(pathspec.iter(), git2::IndexAddOption::DEFAULT, None)?;
49 index.write()?;
50
51 if nothing_left_in_index(repo)? {
52 warn!(
53 logger,
54 "No changes staged, even after auto-staging. \
55 Try adding something to the index."
56 );
57 return Ok(());
58 }
59
60 we_added_everything_to_index = true;
61 } else {
62 warn!(
63 logger,
64 "No changes staged. \
65 Try adding something to the index or set {} = true.",
66 config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME
67 );
68 return Ok(());
69 }
70 }
71
72 let (stack, stack_end_reason) = stack::working_stack(
73 repo,
74 config.base,
75 config.force_author,
76 config.force_detach,
77 logger,
78 )?;
79
80 let mut diff_options = Some({
81 let mut ret = git2::DiffOptions::new();
82 ret.context_lines(0)
83 .id_abbrev(40)
84 .ignore_filemode(true)
85 .ignore_submodules(true);
86 ret
87 });
88
89 let (stack, summary_counts): (Vec<_>, _) = {
90 let mut diffs = Vec::with_capacity(stack.len());
91 for commit in &stack {
92 let diff = owned::Diff::new(
93 &repo.diff_tree_to_tree(
94 if commit.parents().len() == 0 {
95 None
96 } else {
97 Some(commit.parent(0)?.tree()?)
98 }
99 .as_ref(),
100 Some(&commit.tree()?),
101 diff_options.as_mut(),
102 )?,
103 )?;
104 trace!(logger, "parsed commit diff";
105 "commit" => commit.id().to_string(),
106 "diff" => format!("{:?}", diff),
107 );
108 diffs.push(diff);
109 }
110
111 let summary_counts = stack::summary_counts(&stack);
112 (stack.into_iter().zip(diffs).collect(), summary_counts)
113 };
114
115 let mut head_tree = repo.head()?.peel_to_tree()?;
116 let index = owned::Diff::new(&repo.diff_tree_to_index(
117 Some(&head_tree),
118 None,
119 diff_options.as_mut(),
120 )?)?;
121 trace!(logger, "parsed index";
122 "index" => format!("{:?}", index),
123 );
124
125 let signature = repo
126 .signature()
127 .or_else(|_| git2::Signature::now("nobody", "nobody@example.com"))?;
128 let mut head_commit = repo.head()?.peel_to_commit()?;
129
130 let mut hunks_with_commit = vec![];
131
132 let mut modified_hunks_without_target = 0usize;
133 let mut non_modified_patches = 0usize;
134 'patch: for index_patch in index.iter() {
135 let old_path = index_patch.new_path.as_slice();
136 if index_patch.status != git2::Delta::Modified {
137 debug!(logger, "skipped non-modified patch";
138 "path" => String::from_utf8_lossy(old_path).into_owned(),
139 "status" => format!("{:?}", index_patch.status),
140 );
141 non_modified_patches += 1;
142 continue 'patch;
143 }
144
145 let mut preceding_hunks_offset = 0isize;
146 let mut applied_hunks_offset = 0isize;
147 'hunk: for index_hunk in &index_patch.hunks {
148 debug!(logger, "next hunk";
149 "header" => index_hunk.header(),
150 "path" => String::from_utf8_lossy(old_path).into_owned(),
151 );
152
153 let isolated_hunk = index_hunk
159 .clone()
160 .shift_added_block(-preceding_hunks_offset);
161
162 let hunk_to_apply = isolated_hunk
166 .clone()
167 .shift_both_blocks(applied_hunks_offset);
168
169 let hunk_offset = index_hunk.changed_offset();
171
172 debug!(logger, "";
194 "to apply" => hunk_to_apply.header(),
195 "to commute" => isolated_hunk.header(),
196 "preceding hunks" => format!("{}/{}", applied_hunks_offset, preceding_hunks_offset),
197 );
198
199 preceding_hunks_offset += hunk_offset;
200
201 let mut dest_commit = None;
203 let mut commuted_old_path = old_path;
204 let mut commuted_index_hunk = isolated_hunk;
205
206 'commit: for (commit, diff) in &stack {
207 let c_logger = logger.new(o!(
208 "commit" => commit.id().to_string(),
209 ));
210 let next_patch = match diff.by_new(commuted_old_path) {
211 Some(patch) => patch,
212 None => {
216 debug!(c_logger, "skipped commit with no path");
217 continue 'commit;
218 }
219 };
220
221 if config.whole_file {
226 debug!(
227 c_logger,
228 "Commit touches the hunk file and match whole file is enabled"
229 );
230 dest_commit = Some(commit);
231 break 'commit;
232 }
233
234 if next_patch.status == git2::Delta::Added {
235 debug!(c_logger, "found noncommutative commit by add");
236 dest_commit = Some(commit);
237 break 'commit;
238 }
239 if commuted_old_path != next_patch.old_path.as_slice() {
240 debug!(c_logger, "changed commute path";
241 "path" => String::from_utf8_lossy(&next_patch.old_path).into_owned(),
242 );
243 commuted_old_path = next_patch.old_path.as_slice();
244 }
245 commuted_index_hunk = match commute::commute_diff_before(
246 &commuted_index_hunk,
247 &next_patch.hunks,
248 ) {
249 Some(hunk) => {
250 debug!(c_logger, "commuted hunk with commit";
251 "offset" => (hunk.added.start as i64) - (commuted_index_hunk.added.start as i64),
252 );
253 hunk
254 }
255 None => {
258 debug!(c_logger, "found noncommutative commit by conflict");
259 dest_commit = Some(commit);
260 break 'commit;
261 }
262 };
263 }
264 let dest_commit = match dest_commit {
265 Some(commit) => commit,
266 None => {
269 modified_hunks_without_target += 1;
270 continue 'hunk;
271 }
272 };
273
274 let hunk_with_commit = HunkWithCommit {
275 hunk_to_apply,
276 dest_commit,
277 index_patch,
278 };
279 hunks_with_commit.push(hunk_with_commit);
280
281 applied_hunks_offset += hunk_offset;
282 }
283 }
284
285 let target_always_sha: bool = config::fixup_target_always_sha(repo);
286
287 if !config.dry_run {
288 repo.reference("PRE_ABSORB_HEAD", head_commit.id(), true, "")?;
289 }
290
291 for (current, next) in hunks_with_commit
299 .iter()
300 .zip(hunks_with_commit.iter().skip(1).map(Some).chain([None]))
301 {
302 let new_head_tree = apply_hunk_to_tree(
303 repo,
304 &head_tree,
305 ¤t.hunk_to_apply,
306 ¤t.index_patch.old_path,
307 )?;
308
309 let commit_fixup = next.map_or(true, |next| {
311 !config.one_fixup_per_commit || next.dest_commit.id() != current.dest_commit.id()
313 });
314 if commit_fixup {
315 let dest_commit_id = current.dest_commit.id().to_string();
320 let dest_commit_locator = match target_always_sha {
321 true => &dest_commit_id,
322 false => current
323 .dest_commit
324 .summary()
325 .filter(|&msg| summary_counts[msg] == 1)
326 .unwrap_or(&dest_commit_id),
327 };
328 let diff = repo
329 .diff_tree_to_tree(Some(&head_commit.tree()?), Some(&new_head_tree), None)?
330 .stats()?;
331 if !config.dry_run {
332 head_tree = new_head_tree;
333 let mut message = format!("fixup! {}\n", dest_commit_locator);
334 if let Some(m) = config.message.filter(|m| !m.is_empty()) {
335 message.push('\n');
336 message.push_str(m);
337 message.push('\n');
338 };
339 head_commit = repo.find_commit(repo.commit(
340 Some("HEAD"),
341 &signature,
342 &signature,
343 &message,
344 &head_tree,
345 &[&head_commit],
346 )?)?;
347 info!(logger, "committed";
348 "commit" => head_commit.id().to_string(),
349 "header" => format!("+{},-{}", diff.insertions(), diff.deletions()),
350 );
351 } else {
352 info!(logger, "would have committed";
353 "fixup" => dest_commit_locator,
354 "header" => format!("+{},-{}", diff.insertions(), diff.deletions()),
355 );
356 }
357 } else {
358 head_tree = new_head_tree;
360 }
361 }
362
363 if we_added_everything_to_index {
364 let mut index = repo.index()?;
368 index.read_tree(&head_tree)?;
369 index.write()?;
370 }
371
372 if non_modified_patches == index.len() {
373 warn!(
374 logger,
375 "No changes were in-place file modifications. \
376 Added, removed, or renamed files cannot be automatically absorbed."
377 );
378 return Ok(());
379 }
380
381 if non_modified_patches > 0 && !we_added_everything_to_index {
387 warn!(
388 logger,
389 "Some changes were not in-place file modifications. \
390 Added, removed, or renamed files cannot be automatically absorbed."
391 )
392 }
393
394 if modified_hunks_without_target > 0 {
395 warn!(
396 logger,
397 "Some file modifications did not have an available commit to fix up. \
398 You will have to manually create fixup commits."
399 );
400
401 match stack_end_reason {
402 stack::StackEndReason::ReachedRoot => {
403 warn!(
404 logger,
405 "Cannot fix up past first commit in the repository.";
406 );
407 }
408 stack::StackEndReason::ReachedMergeCommit => {
409 warn!(
410 logger,
411 "Cannot fix up past a merge commit";
412 "commit" => match stack.last() {
413 Some(commit) => commit.0.id().to_string(),
414 None => head_commit.id().to_string(),
415 }
416 );
417 }
418 stack::StackEndReason::ReachedAnotherAuthor => {
419 warn!(
420 logger,
421 "Will not fix up past commits by another author. \
422 Use --force-author to override";
423 "commit" => match stack.last() {
424 Some(commit) => commit.0.id().to_string(),
425 None => head_commit.id().to_string(),
426 }
427 );
428 }
429 stack::StackEndReason::ReachedLimit => {
430 warn!(
431 logger,
432 "Will not fix up past maximum stack limit. \
433 Use --base or configure {} to override",
434 config::MAX_STACK_CONFIG_NAME;
435 "limit" => config::max_stack(repo),
436 );
437 }
438 stack::StackEndReason::CommitsHiddenByBase => {
439 warn!(
440 logger,
441 "Will not fix up past specified base commit. \
442 Consider using --base to specify a different base commit";
443 "base" => config.base.unwrap(),
444 );
445 }
446 stack::StackEndReason::CommitsHiddenByBranches => {
447 warn!(
448 logger,
449 "Will not fix up commits reachable by other branches. \
450 Use --base to specify a base commit.";
451 );
452 }
453 }
454 }
455
456 if !hunks_with_commit.is_empty() {
457 use std::process::Command;
458 let last_commit_in_stack = &stack.last().unwrap().0;
460 let number_of_parents = last_commit_in_stack.parents().len();
462 assert!(number_of_parents <= 1);
463
464 let rebase_root = if number_of_parents == 0 {
465 "--root"
466 } else {
467 &*last_commit_in_stack.parent(0)?.id().to_string()
470 };
471
472 let rebase_args = [
473 "rebase",
474 "--interactive",
475 "--autosquash",
476 "--autostash",
477 rebase_root,
478 ];
479
480 if config.and_rebase {
481 let mut command = Command::new("git");
482
483 let repo_path = repo.path().parent().and_then(Path::to_str);
489 match repo_path {
490 Some(path) => {
491 command.args(["-C", path]);
492 }
493 _ => {
494 warn!(
495 logger,
496 "Could not determine repository path for rebase. \
497 Running in current directory."
498 );
499 }
500 }
501
502 command.args(rebase_args);
503
504 for arg in config.rebase_options {
505 command.arg(arg);
506 }
507
508 if config.dry_run {
509 info!(logger, "would have run git rebase"; "command" => format!("{:?}", command));
510 } else {
511 debug!(logger, "running git rebase"; "command" => format!("{:?}", command));
512 command.status().expect("could not run git rebase");
515 }
516 } else if !config.dry_run {
517 info!(logger, "To squash the new commits, rebase:";
518 "command" => format!("git {}", rebase_args.join(" ")),
519 );
520 }
521 }
522
523 Ok(())
524}
525
526struct HunkWithCommit<'c, 'r, 'p> {
527 hunk_to_apply: owned::Hunk,
528 dest_commit: &'c git2::Commit<'r>,
529 index_patch: &'p owned::Patch,
530}
531
532fn apply_hunk_to_tree<'repo>(
533 repo: &'repo git2::Repository,
534 base: &git2::Tree,
535 hunk: &owned::Hunk,
536 path: &[u8],
537) -> Result<git2::Tree<'repo>> {
538 let mut treebuilder = repo.treebuilder(Some(base))?;
539
540 if let Some(slash) = path.iter().position(|&x| x == b'/') {
542 let (first, rest) = path.split_at(slash);
543 let rest = &rest[1..];
544
545 let (subtree, submode) = {
546 let entry = treebuilder
547 .get(first)?
548 .ok_or_else(|| anyhow!("couldn't find tree entry in tree for path"))?;
549 (repo.find_tree(entry.id())?, entry.filemode())
550 };
551 let result_subtree = apply_hunk_to_tree(repo, &subtree, hunk, rest)?;
553
554 treebuilder.insert(first, result_subtree.id(), submode)?;
555 return Ok(repo.find_tree(treebuilder.write()?)?);
556 }
557
558 let (blob, mode) = {
559 let entry = treebuilder
560 .get(path)?
561 .ok_or_else(|| anyhow!("couldn't find blob entry in tree for path"))?;
562 (repo.find_blob(entry.id())?, entry.filemode())
563 };
564
565 let mut blobwriter = repo.blob_writer(None)?;
569 let old_content = blob.content();
570 let (old_start, _, _, _) = hunk.anchors();
571
572 let old_content = {
575 let (pre, post) = split_lines_after(old_content, old_start);
576 blobwriter.write_all(pre)?;
577 post
578 };
579 for line in &*hunk.added.lines {
581 blobwriter.write_all(line)?;
582 }
583 let (_, old_content) = split_lines_after(old_content, hunk.removed.lines.len());
586 blobwriter.write_all(old_content)?;
588
589 treebuilder.insert(path, blobwriter.commit()?, mode)?;
590 Ok(repo.find_tree(treebuilder.write()?)?)
591}
592
593fn split_lines_after(content: &[u8], n: usize) -> (&[u8], &[u8]) {
595 let split_index = if n > 0 {
596 memchr::Memchr::new(b'\n', content)
597 .fuse() .nth(n - 1) .map(|x| x + 1)
600 .unwrap_or_else(|| content.len())
601 } else {
602 0
603 };
604 content.split_at(split_index)
605}
606
607fn nothing_left_in_index(repo: &git2::Repository) -> Result<bool> {
608 let stats = index_stats(repo)?;
609 let nothing = stats.files_changed() == 0 && stats.insertions() == 0 && stats.deletions() == 0;
610 Ok(nothing)
611}
612
613fn index_stats(repo: &git2::Repository) -> Result<git2::DiffStats> {
614 let head = repo.head()?.peel_to_tree()?;
615 let diff = repo.diff_tree_to_index(Some(&head), Some(&repo.index()?), None)?;
616 let stats = diff.stats()?;
617 Ok(stats)
618}
619
620#[cfg(test)]
621mod tests {
622 use git2::message_trailers_strs;
623 use serde_json::json;
624 use std::path::PathBuf;
625
626 use super::*;
627 mod log_utils;
628 pub mod repo_utils;
629
630 #[test]
631 fn no_commits_in_repo() {
632 let dir = tempfile::tempdir().unwrap();
633 let repo = git2::Repository::init_opts(
634 dir.path(),
635 git2::RepositoryInitOptions::new().initial_head("master"),
636 )
637 .unwrap();
638 let capturing_logger = log_utils::CapturingLogger::new();
639 let result = run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &repo);
640 assert!(result
641 .err()
642 .unwrap()
643 .to_string()
644 .starts_with("reference 'refs/heads/master' not found"));
645 }
646
647 #[test]
648 fn multiple_fixups_per_commit() {
649 let ctx = repo_utils::prepare_and_stage();
650
651 let actual_pre_absorb_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap().id();
652
653 let mut capturing_logger = log_utils::CapturingLogger::new();
655 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
656
657 let mut revwalk = ctx.repo.revwalk().unwrap();
658 revwalk.push_head().unwrap();
659 assert_eq!(revwalk.count(), 3);
660
661 assert!(nothing_left_in_index(&ctx.repo).unwrap());
662
663 let pre_absorb_ref_commit = ctx.repo.refname_to_id("PRE_ABSORB_HEAD").unwrap();
664 assert_eq!(pre_absorb_ref_commit, actual_pre_absorb_commit);
665
666 log_utils::assert_log_messages_are(
667 capturing_logger.visible_logs(),
668 vec![
669 &json!({"level": "INFO", "msg": "committed"}),
670 &json!({"level": "INFO", "msg": "committed"}),
671 &json!({
672 "level": "INFO",
673 "msg": "To squash the new commits, rebase:",
674 "command": "git rebase --interactive --autosquash --autostash --root",
675 }),
676 ],
677 );
678 }
679
680 #[test]
681 fn exceed_stack_limit_with_modified_hunk() {
682 let (ctx, file_path) = repo_utils::prepare_repo();
683
684 let parent_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
685 repo_utils::empty_commit_chain(&ctx.repo, "HEAD", &[&parent_commit], config::MAX_STACK);
686 repo_utils::stage_file_changes(&ctx, &file_path);
687
688 let mut capturing_logger = log_utils::CapturingLogger::new();
690 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
691
692 let mut revwalk = ctx.repo.revwalk().unwrap();
693 revwalk.push_head().unwrap();
694 assert_eq!(
695 revwalk.count(),
696 config::MAX_STACK + 1,
697 "Wrong number of commits."
698 );
699
700 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
701 assert!(is_something_in_index);
702
703 log_utils::assert_log_messages_are(
704 capturing_logger.visible_logs(),
705 vec![
706 &json!({
707 "level": "WARN",
708 "msg": "Some file modifications did not have an available commit to fix up. \
709 You will have to manually create fixup commits.",
710 }),
711 &json!({
712 "level": "WARN",
713 "msg": format!(
714 "Will not fix up past maximum stack limit. \
715 Use --base or configure {} to override",
716 config::MAX_STACK_CONFIG_NAME
717 ),
718 "limit": config::MAX_STACK,
719 }),
720 ],
721 );
722 }
723
724 #[test]
725 fn exceed_stack_limit_with_non_modified_patch() {
726 let (ctx, _) = repo_utils::prepare_repo();
729 let parent_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
730 repo_utils::empty_commit_chain(&ctx.repo, "HEAD", &[&parent_commit], config::MAX_STACK);
731 let a_new_file_path = PathBuf::from("a_whole_new_file.txt");
732 std::fs::write(ctx.join(&a_new_file_path), "contents").unwrap();
733 repo_utils::stage_file_changes(&ctx, &a_new_file_path);
734 let another_new_file_path = PathBuf::from("another_whole_new_file.txt");
735 std::fs::write(ctx.join(&another_new_file_path), "contents").unwrap();
736 repo_utils::stage_file_changes(&ctx, &another_new_file_path);
737
738 let mut capturing_logger = log_utils::CapturingLogger::new();
740 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
741
742 let mut revwalk = ctx.repo.revwalk().unwrap();
743 revwalk.push_head().unwrap();
744 assert_eq!(
745 revwalk.count(),
746 config::MAX_STACK + 1,
747 "Wrong number of commits."
748 );
749
750 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
751 assert!(is_something_in_index);
752
753 log_utils::assert_log_messages_are(
754 capturing_logger.visible_logs(),
755 vec![&json!({
756 "level": "WARN",
757 "msg": "No changes were in-place file modifications. \
758 Added, removed, or renamed files cannot be automatically absorbed.",
759 })],
760 );
761 }
762
763 #[test]
764 fn exceed_stack_limit_with_modified_patch_and_non_modified_hunks() {
765 let (ctx, file_path) = repo_utils::prepare_repo();
769 let new_file_path = PathBuf::from("a_whole_new_file.txt");
770 let parent_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
771 repo_utils::empty_commit_chain(&ctx.repo, "HEAD", &[&parent_commit], config::MAX_STACK);
772 std::fs::write(ctx.join(&new_file_path), "contents").unwrap();
773 repo_utils::stage_file_changes(&ctx, &new_file_path);
774 repo_utils::stage_file_changes(&ctx, &file_path);
775
776 let mut capturing_logger = log_utils::CapturingLogger::new();
778 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
779
780 let mut revwalk = ctx.repo.revwalk().unwrap();
781 revwalk.push_head().unwrap();
782 assert_eq!(
783 revwalk.count(),
784 config::MAX_STACK + 1,
785 "Wrong number of commits."
786 );
787
788 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
789 assert!(is_something_in_index);
790
791 log_utils::assert_log_messages_are(
792 capturing_logger.visible_logs(),
793 vec![
794 &json!({
795 "level": "WARN",
796 "msg": "Some changes were not in-place file modifications. \
797 Added, removed, or renamed files cannot be automatically absorbed.",
798 }),
799 &json!({
800 "level": "WARN",
801 "msg": "Some file modifications did not have an available commit to fix up. \
802 You will have to manually create fixup commits.",
803 }),
804 &json!({
805 "level": "WARN",
806 "msg": format!(
807 "Will not fix up past maximum stack limit. \
808 Use --base or configure {} to override",
809 config::MAX_STACK_CONFIG_NAME
810 ),
811 }),
812 ],
813 );
814 }
815
816 #[test]
817 fn reached_root() {
818 let (ctx, _) = repo_utils::prepare_repo();
819 let file_path = PathBuf::from("a_whole_new_file.txt");
820 std::fs::write(ctx.join(&file_path), "contents").unwrap();
821 repo_utils::stage_file_changes(&ctx, &file_path);
822
823 let mut capturing_logger = log_utils::CapturingLogger::new();
825 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
826
827 let mut revwalk = ctx.repo.revwalk().unwrap();
828 revwalk.push_head().unwrap();
829 assert_eq!(revwalk.count(), 1, "Wrong number of commits.");
830
831 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
832 assert!(is_something_in_index);
833
834 log_utils::assert_log_messages_are(
835 capturing_logger.visible_logs(),
836 vec![&json!({
837 "level": "WARN",
838 "msg": "No changes were in-place file modifications. \
839 Added, removed, or renamed files cannot be automatically absorbed."
840 })],
841 );
842 }
843
844 #[test]
845 fn user_defined_base_hides_target_commit() {
846 let ctx = repo_utils::prepare_and_stage();
847
848 let mut capturing_logger = log_utils::CapturingLogger::new();
850 let config = Config {
851 base: Some("HEAD"),
852 ..DEFAULT_CONFIG
853 };
854 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
855
856 let mut revwalk = ctx.repo.revwalk().unwrap();
857 revwalk.push_head().unwrap();
858 assert_eq!(revwalk.count(), 1, "Wrong number of commits.");
859
860 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
861 assert!(is_something_in_index);
862
863 log_utils::assert_log_messages_are(
864 capturing_logger.visible_logs(),
865 vec![
866 &json!({
867 "level": "WARN",
868 "msg": "Some file modifications did not have an available commit to fix up. \
869 You will have to manually create fixup commits.",
870 }),
871 &json!({
872 "level": "WARN",
873 "msg": "Will not fix up past specified base commit. \
874 Consider using --base to specify a different base commit",
875 "base": "HEAD",
876 }),
877 ],
878 );
879 }
880
881 #[test]
882 fn merge_commit_found() {
883 let (ctx, file_path) = repo_utils::prepare_repo();
884 repo_utils::merge_commit(
885 &ctx.repo,
886 &[&ctx.repo.head().unwrap().peel_to_commit().unwrap()],
887 );
888 repo_utils::stage_file_changes(&ctx, &file_path);
889
890 let mut capturing_logger = log_utils::CapturingLogger::new();
892 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
893
894 let mut revwalk = ctx.repo.revwalk().unwrap();
895 revwalk.push_head().unwrap();
896 assert_eq!(revwalk.count(), 4, "Wrong number of commits.");
897
898 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
899 assert!(is_something_in_index);
900
901 log_utils::assert_log_messages_are(
902 capturing_logger.visible_logs(),
903 vec![
904 &json!({
905 "level": "WARN",
906 "msg": "Some file modifications did not have an available commit to fix up. \
907 You will have to manually create fixup commits.",
908 }),
909 &json!({
910 "level": "WARN",
911 "msg": "Cannot fix up past a merge commit",
912 }),
913 ],
914 );
915 }
916
917 #[test]
918 fn merge_commit_before_target_commit() {
919 let (ctx, file_path) = repo_utils::prepare_repo();
920 let merge_commit = repo_utils::merge_commit(
921 &ctx.repo,
922 &[&ctx.repo.head().unwrap().peel_to_commit().unwrap()],
923 );
924
925 std::fs::write(&ctx.join(&file_path), "new content").unwrap();
926 let tree = repo_utils::add(&ctx.repo, &file_path);
927 repo_utils::commit(
928 &ctx.repo,
929 "HEAD",
930 "Change after merge",
931 &tree,
932 &[&merge_commit],
933 );
934
935 repo_utils::stage_file_changes(&ctx, &file_path);
936
937 let mut capturing_logger = log_utils::CapturingLogger::new();
939 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
940
941 let mut revwalk = ctx.repo.revwalk().unwrap();
942 revwalk.push_head().unwrap();
943 assert_eq!(revwalk.count(), 6, "Wrong number of commits.");
944
945 assert!(nothing_left_in_index(&ctx.repo).unwrap());
946
947 log_utils::assert_log_messages_are(
948 capturing_logger.visible_logs(),
949 vec![
950 &json!({"level": "INFO", "msg": "committed",}),
951 &json!({
952 "level": "INFO",
953 "msg": "To squash the new commits, rebase:",
954 "command": format!(
955 "git rebase --interactive --autosquash --autostash {}",
956 merge_commit.id()),
957 }),
958 ],
959 );
960 }
961
962 #[test]
963 fn first_hidden_commit_is_merge() {
964 let (ctx, file_path) = repo_utils::prepare_repo();
965 let merge_commit = repo_utils::merge_commit(
966 &ctx.repo,
967 &[&ctx.repo.head().unwrap().peel_to_commit().unwrap()],
968 );
969 repo_utils::empty_commit(&ctx.repo, "HEAD", "empty commit", &[&merge_commit]);
970 repo_utils::stage_file_changes(&ctx, &file_path);
971
972 let mut capturing_logger = log_utils::CapturingLogger::new();
974 let base_id = merge_commit.id().to_string();
975 let config = Config {
976 base: Some(&base_id),
977 ..DEFAULT_CONFIG
978 };
979 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
980
981 let mut revwalk = ctx.repo.revwalk().unwrap();
982 revwalk.push_head().unwrap();
983 assert_eq!(revwalk.count(), 5, "Wrong number of commits.");
984
985 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
986 assert!(is_something_in_index);
987
988 log_utils::assert_log_messages_are(
989 capturing_logger.visible_logs(),
990 vec![
991 &json!({
992 "level": "WARN",
993 "msg": "Some file modifications did not have an available commit to fix up. \
994 You will have to manually create fixup commits.",
995 }),
996 &json!({
997 "level": "WARN",
998 "msg": "Cannot fix up past a merge commit",
999 }),
1000 ],
1001 );
1002 }
1003
1004 #[test]
1005 fn first_hidden_commit_is_by_another_author() {
1006 let (ctx, file_path) = repo_utils::prepare_repo();
1007 let first_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
1008 ctx.repo
1009 .branch("some-branch", &first_commit, false)
1010 .unwrap();
1011 repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com");
1012 repo_utils::empty_commit(&ctx.repo, "HEAD", "empty commit", &[&first_commit]);
1013 repo_utils::stage_file_changes(&ctx, &file_path);
1014
1015 let mut capturing_logger = log_utils::CapturingLogger::new();
1017 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1018
1019 let mut revwalk = ctx.repo.revwalk().unwrap();
1020 revwalk.push_head().unwrap();
1021 assert_eq!(revwalk.count(), 2, "Wrong number of commits.");
1022
1023 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1024 assert!(is_something_in_index);
1025
1026 log_utils::assert_log_messages_are(
1027 capturing_logger.visible_logs(),
1028 vec![
1029 &json!({
1030 "level": "WARN",
1031 "msg": "Some file modifications did not have an available commit to fix up. \
1032 You will have to manually create fixup commits.",
1033 }),
1034 &json!({
1035 "level": "WARN",
1036 "msg": "Will not fix up past commits by another author. \
1037 Use --force-author to override",
1038 }),
1039 ],
1040 );
1041 }
1042
1043 #[test]
1044 fn first_hidden_commit_is_regular_commit() {
1045 let (ctx, file_path) = repo_utils::prepare_repo();
1046 let first_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
1047 ctx.repo
1048 .branch("some-branch", &first_commit, false)
1049 .unwrap();
1050 repo_utils::empty_commit(&ctx.repo, "HEAD", "empty commit", &[&first_commit]);
1051 repo_utils::stage_file_changes(&ctx, &file_path);
1052
1053 let mut capturing_logger = log_utils::CapturingLogger::new();
1055 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1056
1057 let mut revwalk = ctx.repo.revwalk().unwrap();
1058 revwalk.push_head().unwrap();
1059 assert_eq!(revwalk.count(), 2, "Wrong number of commits.");
1060
1061 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1062 assert!(is_something_in_index);
1063
1064 log_utils::assert_log_messages_are(
1065 capturing_logger.visible_logs(),
1066 vec![
1067 &json!({
1068 "level": "WARN",
1069 "msg": "Some file modifications did not have an available commit to fix up. \
1070 You will have to manually create fixup commits.",
1071 }),
1072 &json!({
1073 "level": "WARN",
1074 "msg": "Will not fix up commits reachable by other branches. \
1075 Use --base to specify a base commit.",
1076 }),
1077 ],
1078 );
1079 }
1080
1081 #[test]
1082 fn one_fixup_per_commit() {
1083 let ctx = repo_utils::prepare_and_stage();
1084
1085 let mut capturing_logger = log_utils::CapturingLogger::new();
1087 let config = Config {
1088 one_fixup_per_commit: true,
1089 ..DEFAULT_CONFIG
1090 };
1091 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1092
1093 let mut revwalk = ctx.repo.revwalk().unwrap();
1094 revwalk.push_head().unwrap();
1095 assert_eq!(revwalk.count(), 2);
1096
1097 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1098
1099 log_utils::assert_log_messages_are(
1100 capturing_logger.visible_logs(),
1101 vec![
1102 &json!({"level": "INFO", "msg": "committed"}),
1103 &json!({
1104 "level": "INFO",
1105 "msg": "To squash the new commits, rebase:",
1106 "command": "git rebase --interactive --autosquash --autostash --root",
1107 }),
1108 ],
1109 );
1110 }
1111
1112 #[test]
1113 fn another_author() {
1114 let ctx = repo_utils::prepare_and_stage();
1115
1116 repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com");
1117
1118 let mut capturing_logger = log_utils::CapturingLogger::new();
1120 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1121
1122 let mut revwalk = ctx.repo.revwalk().unwrap();
1123 revwalk.push_head().unwrap();
1124 assert_eq!(revwalk.count(), 1);
1125 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1126 assert!(is_something_in_index);
1127
1128 log_utils::assert_log_messages_are(
1129 capturing_logger.visible_logs(),
1130 vec![
1131 &json!({
1132 "level": "WARN",
1133 "msg": "Some file modifications did not have an available commit to fix up. \
1134 You will have to manually create fixup commits.",
1135 }),
1136 &json!({
1137 "level": "WARN",
1138 "msg": "Will not fix up past commits by another author. \
1139 Use --force-author to override"
1140 }),
1141 ],
1142 );
1143 }
1144
1145 #[test]
1146 fn another_author_with_force_author_flag() {
1147 let ctx = repo_utils::prepare_and_stage();
1148
1149 repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com");
1150
1151 let mut capturing_logger = log_utils::CapturingLogger::new();
1153 let config = Config {
1154 force_author: true,
1155 ..DEFAULT_CONFIG
1156 };
1157 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1158
1159 let mut revwalk = ctx.repo.revwalk().unwrap();
1160 revwalk.push_head().unwrap();
1161 assert_eq!(revwalk.count(), 3);
1162
1163 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1164
1165 log_utils::assert_log_messages_are(
1166 capturing_logger.visible_logs(),
1167 vec![
1168 &json!({"level": "INFO", "msg": "committed"}),
1169 &json!({"level": "INFO", "msg": "committed"}),
1170 &json!({
1171 "level": "INFO",
1172 "msg": "To squash the new commits, rebase:",
1173 "command": "git rebase --interactive --autosquash --autostash --root",
1174 }),
1175 ],
1176 );
1177 }
1178
1179 #[test]
1180 fn another_author_with_force_author_config() {
1181 let ctx = repo_utils::prepare_and_stage();
1182
1183 repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com");
1184
1185 repo_utils::set_config_flag(&ctx.repo, "absorb.forceAuthor");
1186
1187 let mut capturing_logger = log_utils::CapturingLogger::new();
1189 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1190
1191 let mut revwalk = ctx.repo.revwalk().unwrap();
1192 revwalk.push_head().unwrap();
1193 assert_eq!(revwalk.count(), 3);
1194
1195 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1196
1197 log_utils::assert_log_messages_are(
1198 capturing_logger.visible_logs(),
1199 vec![
1200 &json!({"level": "INFO", "msg": "committed"}),
1201 &json!({"level": "INFO", "msg": "committed"}),
1202 &json!({
1203 "level": "INFO",
1204 "msg": "To squash the new commits, rebase:",
1205 "command": "git rebase --interactive --autosquash --autostash --root",
1206 }),
1207 ],
1208 );
1209 }
1210
1211 #[test]
1212 fn detached_head() {
1213 let ctx = repo_utils::prepare_and_stage();
1214 repo_utils::detach_head(&ctx.repo);
1215
1216 let capturing_logger = log_utils::CapturingLogger::new();
1218 let result = run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo);
1219 assert_eq!(
1220 result.err().unwrap().to_string(),
1221 "HEAD is not a branch, use --force-detach to override"
1222 );
1223
1224 let mut revwalk = ctx.repo.revwalk().unwrap();
1225 revwalk.push_head().unwrap();
1226 assert_eq!(revwalk.count(), 1);
1227 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1228 assert!(is_something_in_index);
1229 }
1230
1231 #[test]
1232 fn detached_head_pointing_at_branch_with_force_detach_flag() {
1233 let ctx = repo_utils::prepare_and_stage();
1234 repo_utils::detach_head(&ctx.repo);
1235
1236 let mut capturing_logger = log_utils::CapturingLogger::new();
1238 let config = Config {
1239 force_detach: true,
1240 ..DEFAULT_CONFIG
1241 };
1242 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1243 let mut revwalk = ctx.repo.revwalk().unwrap();
1244 revwalk.push_head().unwrap();
1245
1246 assert_eq!(revwalk.count(), 1); let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1248 assert!(is_something_in_index);
1249
1250 log_utils::assert_log_messages_are(
1251 capturing_logger.visible_logs(),
1252 vec![
1253 &json!({
1254 "level": "WARN",
1255 "msg": "HEAD is not a branch, but --force-detach used to continue."}),
1256 &json!({
1257 "level": "WARN",
1258 "msg": "Some file modifications did not have an available commit to fix up. \
1259 You will have to manually create fixup commits.",
1260 }),
1261 &json!({
1262 "level": "WARN",
1263 "msg": "Will not fix up commits reachable by other branches. \
1264 Use --base to specify a base commit."
1265 }),
1266 ],
1267 );
1268 }
1269
1270 #[test]
1271 fn detached_head_with_force_detach_flag() {
1272 let ctx = repo_utils::prepare_and_stage();
1273 repo_utils::detach_head(&ctx.repo);
1274 repo_utils::delete_branch(&ctx.repo, "master");
1275
1276 let mut capturing_logger = log_utils::CapturingLogger::new();
1278 let config = Config {
1279 force_detach: true,
1280 ..DEFAULT_CONFIG
1281 };
1282 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1283 let mut revwalk = ctx.repo.revwalk().unwrap();
1284 revwalk.push_head().unwrap();
1285
1286 assert_eq!(revwalk.count(), 3);
1287 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1288
1289 log_utils::assert_log_messages_are(
1290 capturing_logger.visible_logs(),
1291 vec![
1292 &json!({
1293 "level": "WARN",
1294 "msg": "HEAD is not a branch, but --force-detach used to continue.",
1295 }),
1296 &json!({"level": "INFO", "msg": "committed"}),
1297 &json!({"level": "INFO", "msg": "committed"}),
1298 &json!({
1299 "level": "INFO",
1300 "msg": "To squash the new commits, rebase:",
1301 "command": "git rebase --interactive --autosquash --autostash --root",
1302 }),
1303 ],
1304 );
1305 }
1306
1307 #[test]
1308 fn detached_head_with_force_detach_config() {
1309 let ctx = repo_utils::prepare_and_stage();
1310 repo_utils::detach_head(&ctx.repo);
1311 repo_utils::delete_branch(&ctx.repo, "master");
1312
1313 repo_utils::set_config_flag(&ctx.repo, "absorb.forceDetach");
1314
1315 let mut capturing_logger = log_utils::CapturingLogger::new();
1317 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1318 let mut revwalk = ctx.repo.revwalk().unwrap();
1319 revwalk.push_head().unwrap();
1320
1321 assert_eq!(revwalk.count(), 3);
1322 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1323
1324 log_utils::assert_log_messages_are(
1325 capturing_logger.visible_logs(),
1326 vec![
1327 &json!({
1328 "level": "WARN",
1329 "msg": "HEAD is not a branch, but --force-detach used to continue.",
1330 }),
1331 &json!({"level": "INFO", "msg": "committed"}),
1332 &json!({"level": "INFO", "msg": "committed"}),
1333 &json!({
1334 "level": "INFO",
1335 "msg": "To squash the new commits, rebase:",
1336 "command": "git rebase --interactive --autosquash --autostash --root",
1337 }),
1338 ],
1339 );
1340 }
1341
1342 #[test]
1343 fn and_rebase_flag() {
1344 let ctx = repo_utils::prepare_and_stage();
1345 repo_utils::set_config_option(&ctx.repo, "core.editor", "true");
1346 repo_utils::set_config_option(&ctx.repo, "advice.waitingForEditor", "false");
1347
1348 let mut capturing_logger = log_utils::CapturingLogger::new();
1350 let config = Config {
1351 and_rebase: true,
1352 ..DEFAULT_CONFIG
1353 };
1354 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1355
1356 let mut revwalk = ctx.repo.revwalk().unwrap();
1357 revwalk.push_head().unwrap();
1358
1359 assert_eq!(revwalk.count(), 1);
1360 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1361
1362 log_utils::assert_log_messages_are(
1363 capturing_logger.visible_logs(),
1364 vec![
1365 &json!({"level": "INFO", "msg": "committed"}),
1366 &json!({"level": "INFO", "msg": "committed"}),
1367 ],
1368 );
1369 }
1370
1371 #[test]
1372 fn and_rebase_flag_with_rebase_options() {
1373 let ctx = repo_utils::prepare_and_stage();
1374 repo_utils::set_config_option(&ctx.repo, "core.editor", "true");
1375 repo_utils::set_config_option(&ctx.repo, "advice.waitingForEditor", "false");
1376
1377 let mut capturing_logger = log_utils::CapturingLogger::new();
1379 let config = Config {
1380 and_rebase: true,
1381 rebase_options: &vec!["--signoff"],
1382 ..DEFAULT_CONFIG
1383 };
1384 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1385
1386 let mut revwalk = ctx.repo.revwalk().unwrap();
1387 revwalk.push_head().unwrap();
1388 assert_eq!(revwalk.count(), 1);
1389
1390 let trailers = message_trailers_strs(
1391 ctx.repo
1392 .head()
1393 .unwrap()
1394 .peel_to_commit()
1395 .unwrap()
1396 .message()
1397 .unwrap(),
1398 )
1399 .unwrap();
1400 assert_eq!(
1401 trailers
1402 .iter()
1403 .filter(|trailer| trailer.0 == "Signed-off-by")
1404 .count(),
1405 1
1406 );
1407
1408 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1409
1410 log_utils::assert_log_messages_are(
1411 capturing_logger.visible_logs(),
1412 vec![
1413 &json!({"level": "INFO", "msg": "committed"}),
1414 &json!({"level": "INFO", "msg": "committed"}),
1415 ],
1416 );
1417 }
1418
1419 #[test]
1420 fn rebase_options_without_and_rebase_flag() {
1421 let ctx = repo_utils::prepare_and_stage();
1422
1423 let capturing_logger = log_utils::CapturingLogger::new();
1425 let config = Config {
1426 rebase_options: &vec!["--some-option"],
1427 ..DEFAULT_CONFIG
1428 };
1429 let result = run_with_repo(&capturing_logger.logger, &config, &ctx.repo);
1430
1431 assert_eq!(
1432 result.err().unwrap().to_string(),
1433 "REBASE_OPTIONS were specified without --and-rebase flag"
1434 );
1435
1436 let mut revwalk = ctx.repo.revwalk().unwrap();
1437 revwalk.push_head().unwrap();
1438 assert_eq!(revwalk.count(), 1);
1439 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1440 assert!(is_something_in_index);
1441 }
1442
1443 #[test]
1444 fn dry_run_flag() {
1445 let ctx = repo_utils::prepare_and_stage();
1446
1447 let mut capturing_logger = log_utils::CapturingLogger::new();
1449 let config = Config {
1450 dry_run: true,
1451 ..DEFAULT_CONFIG
1452 };
1453 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1454
1455 let mut revwalk = ctx.repo.revwalk().unwrap();
1456 revwalk.push_head().unwrap();
1457 assert_eq!(revwalk.count(), 1);
1458 let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1459 assert!(is_something_in_index);
1460
1461 let pre_absorb_ref_commit = ctx.repo.references_glob("PRE_ABSORB_HEAD").unwrap().last();
1462 assert!(pre_absorb_ref_commit.is_none());
1463
1464 log_utils::assert_log_messages_are(
1465 capturing_logger.visible_logs(),
1466 vec![
1467 &json!({
1468 "level": "INFO",
1469 "msg": "would have committed", "fixup": "Initial commit.",
1470 }),
1471 &json!({
1472 "level": "INFO",
1473 "msg": "would have committed", "fixup": "Initial commit.",
1474 }),
1475 ],
1476 );
1477 }
1478
1479 #[test]
1480 fn dry_run_flag_with_and_rebase_flag() {
1481 let (ctx, path) = repo_utils::prepare_repo();
1482 repo_utils::set_config_option(&ctx.repo, "core.editor", "true");
1483
1484 let tree = repo_utils::stage_file_changes(&ctx, &path);
1486 let head_commit = ctx.repo.head().unwrap().peel_to_commit().unwrap();
1487 let fixup_message = format!("fixup! {}\n", head_commit.id());
1488 repo_utils::commit(&ctx.repo, "HEAD", &fixup_message, &tree, &[&head_commit]);
1489
1490 repo_utils::stage_file_changes(&ctx, &path);
1492
1493 let mut capturing_logger = log_utils::CapturingLogger::new();
1495 let config = Config {
1496 and_rebase: true,
1497 dry_run: true,
1498 ..DEFAULT_CONFIG
1499 };
1500 run_with_repo(&capturing_logger.logger, &config, &ctx.repo).unwrap();
1501
1502 let mut revwalk = ctx.repo.revwalk().unwrap();
1503 revwalk.push_head().unwrap();
1504 assert_eq!(revwalk.count(), 2); let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap();
1506 assert!(is_something_in_index);
1507
1508 log_utils::assert_log_messages_are(
1509 capturing_logger.visible_logs(),
1510 vec![
1511 &json!({"level": "INFO", "msg": "would have committed",}),
1512 &json!({"level": "INFO", "msg": "would have committed",}),
1513 &json!({"level": "INFO", "msg": "would have run git rebase",}),
1514 ],
1515 );
1516 }
1517
1518 fn autostage_common(ctx: &repo_utils::Context, file_path: &PathBuf) -> (PathBuf, PathBuf) {
1519 let path = ctx.join(file_path);
1521 let contents = std::fs::read_to_string(&path).unwrap();
1522 let modifications = format!("{contents}\nnew_line2");
1523 std::fs::write(&path, &modifications).unwrap();
1524
1525 let fp2 = PathBuf::from("unrel.txt");
1527 std::fs::write(ctx.join(&fp2), "foo").unwrap();
1528
1529 (path, fp2)
1530 }
1531
1532 #[test]
1533 fn autostage_if_index_was_empty() {
1534 let (ctx, file_path) = repo_utils::prepare_repo();
1535
1536 ctx.repo
1538 .config()
1539 .unwrap()
1540 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
1541 .unwrap();
1542
1543 autostage_common(&ctx, &file_path);
1544
1545 let mut capturing_logger = log_utils::CapturingLogger::new();
1547 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1548
1549 let mut revwalk = ctx.repo.revwalk().unwrap();
1550 revwalk.push_head().unwrap();
1551 assert_eq!(revwalk.count(), 2);
1552
1553 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1554
1555 log_utils::assert_log_messages_are(
1556 capturing_logger.visible_logs(),
1557 vec![
1558 &json!({"level": "INFO", "msg": "committed"}),
1559 &json!({
1560 "level": "INFO",
1561 "msg": "To squash the new commits, rebase:",
1562 "command": "git rebase --interactive --autosquash --autostash --root",
1563 }),
1564 ],
1565 );
1566 }
1567
1568 #[test]
1569 fn do_not_autostage_if_index_was_not_empty() {
1570 let (ctx, file_path) = repo_utils::prepare_repo();
1571
1572 ctx.repo
1574 .config()
1575 .unwrap()
1576 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
1577 .unwrap();
1578
1579 let (_, fp2) = autostage_common(&ctx, &file_path);
1580 repo_utils::add(&ctx.repo, &fp2);
1582
1583 let mut capturing_logger = log_utils::CapturingLogger::new();
1585 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1586
1587 let mut revwalk = ctx.repo.revwalk().unwrap();
1588 revwalk.push_head().unwrap();
1589 assert_eq!(revwalk.count(), 1);
1590
1591 assert_eq!(index_stats(&ctx.repo).unwrap().files_changed(), 1);
1592
1593 log_utils::assert_log_messages_are(
1594 capturing_logger.visible_logs(),
1595 vec![&json!({
1596 "level": "WARN",
1597 "msg": "No changes were in-place file modifications. \
1598 Added, removed, or renamed files cannot be automatically absorbed."
1599 })],
1600 );
1601 }
1602
1603 #[test]
1604 fn do_not_autostage_if_not_enabled_by_config_var() {
1605 let (ctx, file_path) = repo_utils::prepare_repo();
1606
1607 ctx.repo
1609 .config()
1610 .unwrap()
1611 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, false)
1612 .unwrap();
1613
1614 autostage_common(&ctx, &file_path);
1615
1616 let mut capturing_logger = log_utils::CapturingLogger::new();
1618 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1619
1620 let mut revwalk = ctx.repo.revwalk().unwrap();
1621 revwalk.push_head().unwrap();
1622 assert_eq!(revwalk.count(), 1);
1623
1624 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1625
1626 log_utils::assert_log_messages_are(
1627 capturing_logger.visible_logs(),
1628 vec![&json!({
1629 "level": "WARN",
1630 "msg": format!(
1631 "No changes staged. \
1632 Try adding something to the index or set {} = true.",
1633 config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME,
1634 ),
1635 })],
1636 );
1637 }
1638
1639 #[test]
1640 fn autostage_if_index_was_empty_and_no_changes() {
1641 let (ctx, _file_path) = repo_utils::prepare_repo();
1642
1643 ctx.repo
1645 .config()
1646 .unwrap()
1647 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
1648 .unwrap();
1649
1650 let mut capturing_logger = log_utils::CapturingLogger::new();
1652 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1653
1654 let mut revwalk = ctx.repo.revwalk().unwrap();
1655 revwalk.push_head().unwrap();
1656 assert_eq!(revwalk.count(), 1);
1657
1658 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1659
1660 log_utils::assert_log_messages_are(
1661 capturing_logger.visible_logs(),
1662 vec![&json!({
1663 "level": "WARN",
1664 "msg": "No changes staged, even after auto-staging. \
1665 Try adding something to the index."})],
1666 );
1667 }
1668
1669 #[test]
1670 fn fixup_message_always_commit_sha_if_configured() {
1671 let ctx = repo_utils::prepare_and_stage();
1672
1673 ctx.repo
1674 .config()
1675 .unwrap()
1676 .set_bool(config::FIXUP_TARGET_ALWAYS_SHA_CONFIG_NAME, true)
1677 .unwrap();
1678
1679 let mut capturing_logger = log_utils::CapturingLogger::new();
1681 run_with_repo(&capturing_logger.logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1682 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1683
1684 let mut revwalk = ctx.repo.revwalk().unwrap();
1685 revwalk.push_head().unwrap();
1686
1687 let oids: Vec<git2::Oid> = revwalk.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
1688 assert_eq!(oids.len(), 3);
1689
1690 let commit = ctx.repo.find_commit(oids[0]).unwrap();
1691 let actual_msg = commit.summary().unwrap();
1692 let expected_msg = format!("fixup! {}", oids.last().unwrap());
1693 assert_eq!(actual_msg, expected_msg);
1694
1695 log_utils::assert_log_messages_are(
1696 capturing_logger.visible_logs(),
1697 vec![
1698 &json!({"level": "INFO", "msg": "committed"}),
1699 &json!({"level": "INFO", "msg": "committed"}),
1700 &json!({
1701 "level": "INFO",
1702 "msg": "To squash the new commits, rebase:",
1703 "command": "git rebase --interactive --autosquash --autostash --root",
1704 }),
1705 ],
1706 );
1707 }
1708
1709 #[test]
1710 fn fixup_message_option_left_out_sets_only_summary() {
1711 let ctx = repo_utils::prepare_and_stage();
1712
1713 let drain = slog::Discard;
1715 let logger = slog::Logger::root(drain, o!());
1716 run_with_repo(&logger, &DEFAULT_CONFIG, &ctx.repo).unwrap();
1717 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1718
1719 let mut revwalk = ctx.repo.revwalk().unwrap();
1720 revwalk.push_head().unwrap();
1721
1722 let oids: Vec<git2::Oid> = revwalk.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
1723 assert_eq!(oids.len(), 3);
1724
1725 let fixup_commit = ctx.repo.find_commit(oids[0]).unwrap();
1726 let fixed_up_commit = ctx.repo.find_commit(*oids.last().unwrap()).unwrap();
1727 let actual_msg = fixup_commit.message().unwrap();
1728 let expected_msg = fixed_up_commit.message().unwrap();
1729 let expected_msg = format!("fixup! {}\n", expected_msg);
1730 assert_eq!(actual_msg, expected_msg);
1731 }
1732
1733 #[test]
1734 fn fixup_message_option_provided_sets_message() {
1735 let ctx = repo_utils::prepare_and_stage();
1736
1737 let drain = slog::Discard;
1739 let logger = slog::Logger::root(drain, o!());
1740 let fixup_message_body = "git-absorb is my favorite git tool!";
1741 let config = Config {
1742 message: Some(fixup_message_body),
1743 ..DEFAULT_CONFIG
1744 };
1745 run_with_repo(&logger, &config, &ctx.repo).unwrap();
1746 assert!(nothing_left_in_index(&ctx.repo).unwrap());
1747
1748 let mut revwalk = ctx.repo.revwalk().unwrap();
1749 revwalk.push_head().unwrap();
1750
1751 let oids: Vec<git2::Oid> = revwalk.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
1752 assert_eq!(oids.len(), 3);
1753
1754 let fixup_commit = ctx.repo.find_commit(oids[0]).unwrap();
1755 let fixed_up_commit = ctx.repo.find_commit(*oids.last().unwrap()).unwrap();
1756 let actual_msg = fixup_commit.message().unwrap();
1757 let expected_msg = fixed_up_commit.message().unwrap();
1758 let expected_msg = format!("fixup! {}\n\n{}\n", expected_msg, fixup_message_body);
1759 assert_eq!(actual_msg, expected_msg);
1760 }
1761
1762 const DEFAULT_CONFIG: Config = Config {
1763 dry_run: false,
1764 force_author: false,
1765 force_detach: false,
1766 base: None,
1767 and_rebase: false,
1768 rebase_options: &Vec::new(),
1769 whole_file: false,
1770 one_fixup_per_commit: false,
1771 message: None,
1772 };
1773}