Skip to main content

git_absorb/
lib.rs

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            // no matter from what subdirectory we're executing,
48            // "." will still refer to the root workdir.
49            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            // To properly handle files ("patches" in libgit2 lingo) with multiple hunks, we
149            // need to find the updated line coordinates (`header`) of the current hunk in
150            // two cases:
151            // 1) As if it were the only hunk in the index. This only involves shifting the
152            // "added" side *up* by the offset introduced by the preceding hunks:
153            let isolated_hunk = index_hunk
154                .clone()
155                .shift_added_block(-preceding_hunks_offset);
156
157            // 2) When applied on top of the previously committed hunks. This requires shifting
158            // both the "added" and the "removed" sides of the previously isolated hunk *down*
159            // by the offset of the committed hunks:
160            let hunk_to_apply = isolated_hunk
161                .clone()
162                .shift_both_blocks(applied_hunks_offset);
163
164            // The offset is the number of lines added minus the number of lines removed by a hunk:
165            let hunk_offset = index_hunk.changed_offset();
166
167            // To aid in understanding these arithmetic, here's an illustration.
168            // There are two hunks in the original patch, each adding one line ("line2" and
169            // "line5"). Assuming the first hunk (with offset = -1) was already processed
170            // and applied, the table shows the three versions of the patch, with line numbers
171            // on the <A>dded and <R>emoved sides for each:
172            // |----------------|-----------|------------------|
173            // |                |           | applied on top   |
174            // | original patch | isolated  | of the preceding |
175            // |----------------|-----------|------------------|
176            // | <R> <A>        | <R> <A>   | <R> <A>          |
177            // |----------------|-----------|------------------|
178            // |  1   1  line1  |  1   1    |  1   1   line1   |
179            // |  2      line2  |  2   2    |  2   2   line3   |
180            // |  3   2  line3  |  3   3    |  3   3   line4   |
181            // |  4   3  line4  |  4   4    |  4       line5   |
182            // |  5      line5  |  5        |                  |
183            // |----------------|-----------|------------------|
184            // |       So the second hunk's `header` is:       |
185            // |   -5,1 +3,0    | -5,1 +4,0 |    -4,1 +3,0     |
186            // |----------------|-----------|------------------|
187
188            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            // find the newest commit that the hunk cannot commute with
197            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                    // this commit doesn't touch the hunk's file, so
208                    // they trivially commute, and the next commit
209                    // should be considered
210                    None => {
211                        debug!(c_logger, "skipped commit with no path");
212                        continue 'commit;
213                    }
214                };
215
216                // sometimes we just forget some change (eg: intializing some object) that
217                // happens in a completely unrelated place with the current hunks. In those
218                // cases, might be helpful to just match the first commit touching the same
219                // file as the current hunk. Use this option with care!
220                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                    // this commit contains a hunk that cannot
251                    // commute with the hunk being absorbed
252                    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                // the hunk commutes with every commit in the stack,
262                // so there is no commit to absorb it into
263                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    // * apply all hunks that are going to be fixed up into `dest_commit`
287    // * commit the fixup
288    // * repeat for all `dest_commit`s
289    //
290    // the `.zip` here will gives us something similar to `.windows`, but with
291    // an extra iteration for the last element (otherwise we would have to
292    // special case the last element and commit it separately)
293    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            &current.hunk_to_apply,
301            &current.index_patch.old_path,
302        )?;
303
304        // whether there are no more hunks to apply to `dest_commit`
305        let commit_fixup = next.map_or(true, |next| {
306            // if the next hunk is for a different commit -- commit what we have so far
307            !config.one_fixup_per_commit || next.dest_commit.id() != current.dest_commit.id()
308        });
309        if commit_fixup {
310            // TODO: the git2 api only supports utf8 commit messages,
311            // so it's okay to use strings instead of bytes here
312            // https://docs.rs/git2/0.7.5/src/git2/repo.rs.html#998
313            // https://libgit2.org/libgit2/#HEAD/group/commit/git_commit_create
314            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            // we didn't commit anything, but we applied a hunk
355            head_tree = new_head_tree;
356        }
357    }
358
359    if we_added_everything_to_index {
360        // now that the fixup commits have been created,
361        // we should unstage the remaining changes from the index.
362
363        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    // So long as there was a patch that had the possibility of fixing up
374    // a commit, warn about the presence of patches that will commute with
375    // everything.
376    // Users that auto-stage changes may be accustomed to having untracked files
377    // in their workspace that are not absorbed, so don't warn them.
378    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        // unwrap() is safe here, as we exit early if the stack is empty
424        let last_commit_in_stack = &stack.last().unwrap().0;
425        // The stack isn't supposed to have any merge commits, per the check in working_stack()
426        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            // Use a range that is guaranteed to include all the commits we might have
433            // committed "fixup!" commits for.
434            &*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            // We'd generally expect to be run from within the repository, but just in case,
449            // try to have git run rebase from the repository root.
450            // This simplifies writing tests that execute from within git-absorb's source directory
451            // but operate on temporary repositories created elsewhere.
452            // (The tests could explicitly change directories, but then must be serialized.)
453            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                // Don't check that we have successfully absorbed everything, nor git's
474                // exit code -- as git will print helpful messages on its own.
475                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    // recurse into nested tree if applicable
500    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        // TODO: loop instead of recursing to avoid potential stack overflow
511        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    // TODO: convert path to OsStr and pass it during blob_writer
525    // creation, to get gitattributes handling (note that converting
526    // &[u8] to &std::path::Path is only possible on unixy platforms)
527    let mut blobwriter = repo.blob_writer(None)?;
528    let old_content = blob.content();
529    let (old_start, _, _, _) = hunk.anchors();
530
531    // first, write the lines from the old content that are above the
532    // hunk
533    let old_content = {
534        let (pre, post) = split_lines_after(old_content, old_start);
535        blobwriter.write_all(pre)?;
536        post
537    };
538    // next, write the added side of the hunk
539    for line in &*hunk.added.lines {
540        blobwriter.write_all(line)?;
541    }
542    // if this hunk removed lines from the old content, those must be
543    // skipped
544    let (_, old_content) = split_lines_after(old_content, hunk.removed.lines.len());
545    // finally, write the remaining lines of the old content
546    blobwriter.write_all(old_content)?;
547
548    treebuilder.insert(path, blobwriter.commit()?, mode)?;
549    Ok(repo.find_tree(treebuilder.write()?)?)
550}
551
552/// Return slices for lines [1..n] and [n+1; ...]
553fn 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() // TODO: is fuse necessary here?
557            .nth(n - 1) // the position of '\n' ending the `n`-th line
558            .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
579// Messages that will be shown to users during normal operations (not debug messages).
580enum 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        // run 'git-absorb'
760        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        // run 'git-absorb'
821        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        // run 'git-absorb'
870        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        // run 'git-absorb'
909        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        // non-modified patches commute with everything, and
947        // have special handling above, so make sure we test with one
948        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        // run 'git-absorb'
959        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        // non-modified patches commute with everything, and
986        // have special handling above. Test with both modified hunks (a patch is made of hunks)
987        // and non-modified patches to ensure we don't confuse the messaging to the user.
988        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        // run 'git-absorb'
997        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            // to have a predictable number of commits for unit test
1047            one_fixup_per_commit: true,
1048            ..DEFAULT_CONFIG
1049        };
1050
1051        // run 'git-absorb'
1052        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            // initial + 10 empty + fixup
1061            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        // run 'git-absorb'
1076        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        // run 'git-absorb'
1101        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        // run 'git-absorb'
1143        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        // run 'git-absorb'
1190        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        // run 'git-absorb'
1225        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        // run 'git-absorb'
1268        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        // run 'git-absorb'
1306        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        // run 'git-absorb'
1338        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        // run 'git-absorb'
1376        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        // run 'git-absorb'
1409        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        // run 'git-absorb'
1445        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        // run 'git-absorb'
1474        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        // run 'git-absorb'
1494        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); // nothing was committed
1504        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        // run 'git-absorb'
1534        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        // run 'git-absorb'
1573        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        // run 'git-absorb'
1606        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        // run 'git-absorb'
1635        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        // run 'git-absorb'
1681        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        // run 'git-absorb'
1705        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        // run 'git-absorb'
1742        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        // run 'git-absorb'
1773        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        // create a fixup commit that 'git rebase' will act on if called
1814        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        // stage one more change so 'git-absorb' won't exit early
1820        repo_utils::stage_file_changes(&ctx, &path);
1821
1822        // run 'git-absorb'
1823        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); // git rebase wasn't called so both commits persist
1834        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        // 1 modification w/o staging
1849        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        // 1 extra file
1855        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        // requires enabled config var
1866        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        // run 'git-absorb'
1875        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        // enable config var
1902        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        // we stage the extra file - should stay in index
1910        repo_utils::add(&ctx.repo, &fp2);
1911
1912        // run 'git-absorb'
1913        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        // disable config var
1937        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        // run 'git-absorb'
1946        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        // requires enabled config var
1973        ctx.repo
1974            .config()
1975            .unwrap()
1976            .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
1977            .unwrap();
1978
1979        // run 'git-absorb'
1980        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        // run 'git-absorb'
2009        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        // run 'git-absorb'
2043        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        // run 'git-absorb'
2067        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    /// Perform a revwalk from HEAD, extracting the commit messages.
2092    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}