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 std::io::Write;
11
12pub struct Config<'a> {
13    pub dry_run: bool,
14    pub force: bool,
15    pub base: Option<&'a str>,
16    pub and_rebase: bool,
17    pub whole_file: bool,
18    pub one_fixup_per_commit: bool,
19    pub logger: &'a slog::Logger,
20}
21
22pub fn run(config: &mut Config) -> Result<()> {
23    let repo = git2::Repository::open_from_env()?;
24    debug!(config.logger, "repository found"; "path" => repo.path().to_str());
25
26    // here, we default to the git config value,
27    // if the flag was not provided in the CLI.
28    //
29    // in the future, we'd likely want to differentiate between
30    // a "non-provided" option, vs an explicit --no-<option>
31    // that disables a behavior, much like git does.
32    // e.g. user may want to overwrite a config value with
33    // --no-one-fixup-per-commit -- then, defaulting to the config value
34    // like we do here is no longer sufficient. but until then, this is fine.
35    //
36    config.one_fixup_per_commit |= config::one_fixup_per_commit(&repo);
37
38    run_with_repo(config, &repo)
39}
40
41fn run_with_repo(config: &Config, repo: &git2::Repository) -> Result<()> {
42    let stack = stack::working_stack(repo, config.base, config.force, config.logger)?;
43    if stack.is_empty() {
44        crit!(config.logger, "No commits available to fix up, exiting");
45        return Ok(());
46    }
47
48    let autostage_enabled = config::auto_stage_if_nothing_staged(repo);
49    let index_was_empty = nothing_left_in_index(repo)?;
50    let mut we_added_everything_to_index = false;
51    if autostage_enabled && index_was_empty {
52        we_added_everything_to_index = true;
53
54        // no matter from what subdirectory we're executing,
55        // "." will still refer to the root workdir.
56        let pathspec = ["."];
57        let mut index = repo.index()?;
58        index.add_all(pathspec.iter(), git2::IndexAddOption::DEFAULT, None)?;
59        index.write()?;
60    }
61
62    let mut diff_options = Some({
63        let mut ret = git2::DiffOptions::new();
64        ret.context_lines(0)
65            .id_abbrev(40)
66            .ignore_filemode(true)
67            .ignore_submodules(true);
68        ret
69    });
70
71    let (stack, summary_counts): (Vec<_>, _) = {
72        let mut diffs = Vec::with_capacity(stack.len());
73        for commit in &stack {
74            let diff = owned::Diff::new(
75                &repo.diff_tree_to_tree(
76                    if commit.parents().len() == 0 {
77                        None
78                    } else {
79                        Some(commit.parent(0)?.tree()?)
80                    }
81                    .as_ref(),
82                    Some(&commit.tree()?),
83                    diff_options.as_mut(),
84                )?,
85            )?;
86            trace!(config.logger, "parsed commit diff";
87                   "commit" => commit.id().to_string(),
88                   "diff" => format!("{:?}", diff),
89            );
90            diffs.push(diff);
91        }
92
93        let summary_counts = stack::summary_counts(&stack);
94        (stack.into_iter().zip(diffs).collect(), summary_counts)
95    };
96
97    let mut head_tree = repo.head()?.peel_to_tree()?;
98    let index = owned::Diff::new(&repo.diff_tree_to_index(
99        Some(&head_tree),
100        None,
101        diff_options.as_mut(),
102    )?)?;
103    trace!(config.logger, "parsed index";
104           "index" => format!("{:?}", index),
105    );
106
107    let signature = repo
108        .signature()
109        .or_else(|_| git2::Signature::now("nobody", "nobody@example.com"))?;
110    let mut head_commit = repo.head()?.peel_to_commit()?;
111
112    let mut hunks_with_commit = vec![];
113
114    let mut patches_considered = 0usize;
115    'patch: for index_patch in index.iter() {
116        let old_path = index_patch.new_path.as_slice();
117        if index_patch.status != git2::Delta::Modified {
118            debug!(config.logger, "skipped non-modified hunk";
119                    "path" => String::from_utf8_lossy(old_path).into_owned(),
120                    "status" => format!("{:?}", index_patch.status),
121            );
122            continue 'patch;
123        }
124
125        patches_considered += 1;
126
127        let mut preceding_hunks_offset = 0isize;
128        let mut applied_hunks_offset = 0isize;
129        'hunk: for index_hunk in &index_patch.hunks {
130            debug!(config.logger, "next hunk";
131                   "header" => index_hunk.header(),
132                   "path" => String::from_utf8_lossy(old_path).into_owned(),
133            );
134
135            // To properly handle files ("patches" in libgit2 lingo) with multiple hunks, we
136            // need to find the updated line coordinates (`header`) of the current hunk in
137            // two cases:
138            // 1) As if it were the only hunk in the index. This only involves shifting the
139            // "added" side *up* by the offset introduced by the preceding hunks:
140            let isolated_hunk = index_hunk
141                .clone()
142                .shift_added_block(-preceding_hunks_offset);
143
144            // 2) When applied on top of the previously committed hunks. This requires shifting
145            // both the "added" and the "removed" sides of the previously isolated hunk *down*
146            // by the offset of the committed hunks:
147            let hunk_to_apply = isolated_hunk
148                .clone()
149                .shift_both_blocks(applied_hunks_offset);
150
151            // The offset is the number of lines added minus the number of lines removed by a hunk:
152            let hunk_offset = index_hunk.changed_offset();
153
154            // To aid in understanding these arithmetic, here's an illustration.
155            // There are two hunks in the original patch, each adding one line ("line2" and
156            // "line5"). Assuming the first hunk (with offset = -1) was already processed
157            // and applied, the table shows the three versions of the patch, with line numbers
158            // on the <A>dded and <R>emoved sides for each:
159            // |----------------|-----------|------------------|
160            // |                |           | applied on top   |
161            // | original patch | isolated  | of the preceding |
162            // |----------------|-----------|------------------|
163            // | <R> <A>        | <R> <A>   | <R> <A>          |
164            // |----------------|-----------|------------------|
165            // |  1   1  line1  |  1   1    |  1   1   line1   |
166            // |  2      line2  |  2   2    |  2   2   line3   |
167            // |  3   2  line3  |  3   3    |  3   3   line4   |
168            // |  4   3  line4  |  4   4    |  4       line5   |
169            // |  5      line5  |  5        |                  |
170            // |----------------|-----------|------------------|
171            // |       So the second hunk's `header` is:       |
172            // |   -5,1 +3,0    | -5,1 +4,0 |    -4,1 +3,0     |
173            // |----------------|-----------|------------------|
174
175            debug!(config.logger, "";
176                "to apply" => hunk_to_apply.header(),
177                "to commute" => isolated_hunk.header(),
178                "preceding hunks" => format!("{}/{}", applied_hunks_offset, preceding_hunks_offset),
179            );
180
181            preceding_hunks_offset += hunk_offset;
182
183            // find the newest commit that the hunk cannot commute with
184            let mut dest_commit = None;
185            let mut commuted_old_path = old_path;
186            let mut commuted_index_hunk = isolated_hunk;
187
188            'commit: for (commit, diff) in &stack {
189                let c_logger = config.logger.new(o!(
190                    "commit" => commit.id().to_string(),
191                ));
192                let next_patch = match diff.by_new(commuted_old_path) {
193                    Some(patch) => patch,
194                    // this commit doesn't touch the hunk's file, so
195                    // they trivially commute, and the next commit
196                    // should be considered
197                    None => {
198                        debug!(c_logger, "skipped commit with no path");
199                        continue 'commit;
200                    }
201                };
202
203                // sometimes we just forget some change (eg: intializing some object) that
204                // happens in a completely unrelated place with the current hunks. In those
205                // cases, might be helpful to just match the first commit touching the same
206                // file as the current hunk. Use this option with care!
207                if config.whole_file {
208                    debug!(
209                        c_logger,
210                        "Commit touches the hunk file and match whole file is enabled"
211                    );
212                    dest_commit = Some(commit);
213                    break 'commit;
214                }
215
216                if next_patch.status == git2::Delta::Added {
217                    debug!(c_logger, "found noncommutative commit by add");
218                    dest_commit = Some(commit);
219                    break 'commit;
220                }
221                if commuted_old_path != next_patch.old_path.as_slice() {
222                    debug!(c_logger, "changed commute path";
223                           "path" => String::from_utf8_lossy(&next_patch.old_path).into_owned(),
224                    );
225                    commuted_old_path = next_patch.old_path.as_slice();
226                }
227                commuted_index_hunk = match commute::commute_diff_before(
228                    &commuted_index_hunk,
229                    &next_patch.hunks,
230                ) {
231                    Some(hunk) => {
232                        debug!(c_logger, "commuted hunk with commit";
233                               "offset" => (hunk.added.start as i64) - (commuted_index_hunk.added.start as i64),
234                        );
235                        hunk
236                    }
237                    // this commit contains a hunk that cannot
238                    // commute with the hunk being absorbed
239                    None => {
240                        debug!(c_logger, "found noncommutative commit by conflict");
241                        dest_commit = Some(commit);
242                        break 'commit;
243                    }
244                };
245            }
246            let dest_commit = match dest_commit {
247                Some(commit) => commit,
248                // the hunk commutes with every commit in the stack,
249                // so there is no commit to absorb it into
250                None => {
251                    warn!(
252                        config.logger,
253                        "Could not find a commit to fix up, use \
254                         --base to increase the search range."
255                    );
256                    continue 'hunk;
257                }
258            };
259
260            let hunk_with_commit = HunkWithCommit {
261                hunk_to_apply,
262                dest_commit,
263                index_patch,
264            };
265            hunks_with_commit.push(hunk_with_commit);
266
267            applied_hunks_offset += hunk_offset;
268        }
269    }
270
271    let target_always_sha: bool = config::fixup_target_always_sha(repo);
272
273    hunks_with_commit.sort_by_key(|h| h.dest_commit.id());
274    // * apply all hunks that are going to be fixed up into `dest_commit`
275    // * commit the fixup
276    // * repeat for all `dest_commit`s
277    //
278    // the `.zip` here will gives us something similar to `.windows`, but with
279    // an extra iteration for the last element (otherwise we would have to
280    // special case the last element and commit it separately)
281    for (current, next) in hunks_with_commit
282        .iter()
283        .zip(hunks_with_commit.iter().skip(1).map(Some).chain([None]))
284    {
285        let new_head_tree = apply_hunk_to_tree(
286            repo,
287            &head_tree,
288            &current.hunk_to_apply,
289            &current.index_patch.old_path,
290        )?;
291
292        // whether there are no more hunks to apply to `dest_commit`
293        let commit_fixup = next.map_or(true, |next| {
294            // if the next hunk is for a different commit -- commit what we have so far
295            !config.one_fixup_per_commit || next.dest_commit.id() != current.dest_commit.id()
296        });
297        if commit_fixup {
298            // TODO: the git2 api only supports utf8 commit messages,
299            // so it's okay to use strings instead of bytes here
300            // https://docs.rs/git2/0.7.5/src/git2/repo.rs.html#998
301            // https://libgit2.org/libgit2/#HEAD/group/commit/git_commit_create
302            let dest_commit_id = current.dest_commit.id().to_string();
303            let dest_commit_locator = match target_always_sha {
304                true => &dest_commit_id,
305                false => current
306                    .dest_commit
307                    .summary()
308                    .filter(|&msg| summary_counts[msg] == 1)
309                    .unwrap_or(&dest_commit_id),
310            };
311            let diff = repo
312                .diff_tree_to_tree(Some(&head_commit.tree()?), Some(&new_head_tree), None)?
313                .stats()?;
314            if !config.dry_run {
315                head_tree = new_head_tree;
316                head_commit = repo.find_commit(repo.commit(
317                    Some("HEAD"),
318                    &signature,
319                    &signature,
320                    &format!("fixup! {}\n", dest_commit_locator),
321                    &head_tree,
322                    &[&head_commit],
323                )?)?;
324                info!(config.logger, "committed";
325                      "commit" => head_commit.id().to_string(),
326                      "header" => format!("+{},-{}", diff.insertions(), diff.deletions()),
327                );
328            } else {
329                info!(config.logger, "would have committed";
330                      "fixup" => dest_commit_locator,
331                      "header" => format!("+{},-{}", diff.insertions(), diff.deletions()),
332                );
333            }
334        } else {
335            // we didn't commit anything, but we applied a hunk
336            head_tree = new_head_tree;
337        }
338    }
339
340    if autostage_enabled && we_added_everything_to_index {
341        // now that the fixup commits have been created,
342        // we should unstage the remaining changes from the index.
343
344        let mut index = repo.index()?;
345        index.read_tree(&head_tree)?;
346        index.write()?;
347    }
348
349    if patches_considered == 0 {
350        if index_was_empty && !we_added_everything_to_index {
351            warn!(
352                config.logger,
353                "No changes staged, try adding something \
354                 to the index or set {} = true",
355                config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME
356            );
357        } else {
358            warn!(
359                config.logger,
360                "Could not find a commit to fix up, use \
361                 --base to increase the search range."
362            )
363        }
364    } else if config.and_rebase {
365        use std::process::Command;
366        // unwrap() is safe here, as we exit early if the stack is empty
367        let last_commit_in_stack = &stack.last().unwrap().0;
368        // The stack isn't supposed to have any merge commits, per the check in working_stack()
369        let number_of_parents = last_commit_in_stack.parents().len();
370        assert!(number_of_parents <= 1);
371
372        let mut command = Command::new("git");
373        command.args(["rebase", "--interactive", "--autosquash", "--autostash"]);
374
375        if number_of_parents == 0 {
376            command.arg("--root");
377        } else {
378            // Use a range that is guaranteed to include all the commits we might have
379            // committed "fixup!" commits for.
380            let base_commit_sha = last_commit_in_stack.parent(0)?.id().to_string();
381            command.arg(&base_commit_sha);
382        }
383
384        // Don't check that we have successfully absorbed everything, nor git's
385        // exit code -- as git will print helpful messages on its own.
386        command.status().expect("could not run git rebase");
387    }
388
389    Ok(())
390}
391
392struct HunkWithCommit<'c, 'r, 'p> {
393    hunk_to_apply: owned::Hunk,
394    dest_commit: &'c git2::Commit<'r>,
395    index_patch: &'p owned::Patch,
396}
397
398fn apply_hunk_to_tree<'repo>(
399    repo: &'repo git2::Repository,
400    base: &git2::Tree,
401    hunk: &owned::Hunk,
402    path: &[u8],
403) -> Result<git2::Tree<'repo>> {
404    let mut treebuilder = repo.treebuilder(Some(base))?;
405
406    // recurse into nested tree if applicable
407    if let Some(slash) = path.iter().position(|&x| x == b'/') {
408        let (first, rest) = path.split_at(slash);
409        let rest = &rest[1..];
410
411        let (subtree, submode) = {
412            let entry = treebuilder
413                .get(first)?
414                .ok_or_else(|| anyhow!("couldn't find tree entry in tree for path"))?;
415            (repo.find_tree(entry.id())?, entry.filemode())
416        };
417        // TODO: loop instead of recursing to avoid potential stack overflow
418        let result_subtree = apply_hunk_to_tree(repo, &subtree, hunk, rest)?;
419
420        treebuilder.insert(first, result_subtree.id(), submode)?;
421        return Ok(repo.find_tree(treebuilder.write()?)?);
422    }
423
424    let (blob, mode) = {
425        let entry = treebuilder
426            .get(path)?
427            .ok_or_else(|| anyhow!("couldn't find blob entry in tree for path"))?;
428        (repo.find_blob(entry.id())?, entry.filemode())
429    };
430
431    // TODO: convert path to OsStr and pass it during blob_writer
432    // creation, to get gitattributes handling (note that converting
433    // &[u8] to &std::path::Path is only possible on unixy platforms)
434    let mut blobwriter = repo.blob_writer(None)?;
435    let old_content = blob.content();
436    let (old_start, _, _, _) = hunk.anchors();
437
438    // first, write the lines from the old content that are above the
439    // hunk
440    let old_content = {
441        let (pre, post) = split_lines_after(old_content, old_start);
442        blobwriter.write_all(pre)?;
443        post
444    };
445    // next, write the added side of the hunk
446    for line in &*hunk.added.lines {
447        blobwriter.write_all(line)?;
448    }
449    // if this hunk removed lines from the old content, those must be
450    // skipped
451    let (_, old_content) = split_lines_after(old_content, hunk.removed.lines.len());
452    // finally, write the remaining lines of the old content
453    blobwriter.write_all(old_content)?;
454
455    treebuilder.insert(path, blobwriter.commit()?, mode)?;
456    Ok(repo.find_tree(treebuilder.write()?)?)
457}
458
459/// Return slices for lines [1..n] and [n+1; ...]
460fn split_lines_after(content: &[u8], n: usize) -> (&[u8], &[u8]) {
461    let split_index = if n > 0 {
462        memchr::Memchr::new(b'\n', content)
463            .fuse() // TODO: is fuse necessary here?
464            .nth(n - 1) // the position of '\n' ending the `n`-th line
465            .map(|x| x + 1)
466            .unwrap_or_else(|| content.len())
467    } else {
468        0
469    };
470    content.split_at(split_index)
471}
472
473fn nothing_left_in_index(repo: &git2::Repository) -> Result<bool> {
474    let stats = index_stats(repo)?;
475    let nothing = stats.files_changed() == 0 && stats.insertions() == 0 && stats.deletions() == 0;
476    Ok(nothing)
477}
478
479fn index_stats(repo: &git2::Repository) -> Result<git2::DiffStats> {
480    let head = repo.head()?.peel_to_tree()?;
481    let diff = repo.diff_tree_to_index(Some(&head), Some(&repo.index()?), None)?;
482    let stats = diff.stats()?;
483    Ok(stats)
484}
485
486#[cfg(test)]
487mod tests {
488    use std::path::{Path, PathBuf};
489
490    use super::*;
491
492    struct Context {
493        repo: git2::Repository,
494        dir: tempfile::TempDir,
495    }
496
497    impl Context {
498        fn join(&self, p: &Path) -> PathBuf {
499            self.dir.path().join(p)
500        }
501    }
502
503    /// Prepare a fresh git repository with an initial commit and a file.
504    fn prepare_repo() -> (Context, PathBuf) {
505        let dir = tempfile::tempdir().unwrap();
506        let repo = git2::Repository::init(dir.path()).unwrap();
507
508        let path = PathBuf::from("test-file.txt");
509        std::fs::write(
510            dir.path().join(&path),
511            br#"
512line
513line
514
515more
516lines
517"#,
518        )
519        .unwrap();
520
521        // make the borrow-checker happy by introducing a new scope
522        {
523            let tree = add(&repo, &path);
524            let signature = repo
525                .signature()
526                .or_else(|_| git2::Signature::now("nobody", "nobody@example.com"))
527                .unwrap();
528            repo.commit(
529                Some("HEAD"),
530                &signature,
531                &signature,
532                "Initial commit.",
533                &tree,
534                &[],
535            )
536            .unwrap();
537        }
538
539        (Context { repo, dir }, path)
540    }
541
542    /// Stage the changes made to `path`.
543    fn add<'r>(repo: &'r git2::Repository, path: &Path) -> git2::Tree<'r> {
544        let mut index = repo.index().unwrap();
545        index.add_path(&path).unwrap();
546        index.write().unwrap();
547
548        let tree_id = index.write_tree_to(&repo).unwrap();
549        repo.find_tree(tree_id).unwrap()
550    }
551
552    /// Prepare an empty repo, and stage some changes.
553    fn prepare_and_stage() -> Context {
554        let (ctx, file_path) = prepare_repo();
555
556        // add some lines to our file
557        let path = ctx.join(&file_path);
558        let contents = std::fs::read_to_string(&path).unwrap();
559        let modifications = format!("new_line1\n{contents}\nnew_line2");
560        std::fs::write(&path, &modifications).unwrap();
561
562        // stage it
563        add(&ctx.repo, &file_path);
564
565        ctx
566    }
567
568    #[test]
569    fn multiple_fixups_per_commit() {
570        let ctx = prepare_and_stage();
571
572        // run 'git-absorb'
573        let drain = slog::Discard;
574        let logger = slog::Logger::root(drain, o!());
575        let config = Config {
576            dry_run: false,
577            force: false,
578            base: None,
579            and_rebase: false,
580            whole_file: false,
581            one_fixup_per_commit: false,
582            logger: &logger,
583        };
584        run_with_repo(&config, &ctx.repo).unwrap();
585
586        let mut revwalk = ctx.repo.revwalk().unwrap();
587        revwalk.push_head().unwrap();
588        assert_eq!(revwalk.count(), 3);
589
590        assert!(nothing_left_in_index(&ctx.repo).unwrap());
591    }
592
593    #[test]
594    fn one_fixup_per_commit() {
595        let ctx = prepare_and_stage();
596
597        // run 'git-absorb'
598        let drain = slog::Discard;
599        let logger = slog::Logger::root(drain, o!());
600        let config = Config {
601            dry_run: false,
602            force: false,
603            base: None,
604            and_rebase: false,
605            whole_file: false,
606            one_fixup_per_commit: true,
607            logger: &logger,
608        };
609        run_with_repo(&config, &ctx.repo).unwrap();
610
611        let mut revwalk = ctx.repo.revwalk().unwrap();
612        revwalk.push_head().unwrap();
613        assert_eq!(revwalk.count(), 2);
614
615        assert!(nothing_left_in_index(&ctx.repo).unwrap());
616    }
617
618    fn autostage_common(ctx: &Context, file_path: &PathBuf) -> (PathBuf, PathBuf) {
619        // 1 modification w/o staging
620        let path = ctx.join(&file_path);
621        let contents = std::fs::read_to_string(&path).unwrap();
622        let modifications = format!("{contents}\nnew_line2");
623        std::fs::write(&path, &modifications).unwrap();
624
625        // 1 extra file
626        let fp2 = PathBuf::from("unrel.txt");
627        std::fs::write(ctx.join(&fp2), "foo").unwrap();
628
629        (path, fp2)
630    }
631
632    #[test]
633    fn autostage_if_index_was_empty() {
634        let (ctx, file_path) = prepare_repo();
635
636        // requires enabled config var
637        ctx.repo
638            .config()
639            .unwrap()
640            .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
641            .unwrap();
642
643        autostage_common(&ctx, &file_path);
644
645        // run 'git-absorb'
646        let drain = slog::Discard;
647        let logger = slog::Logger::root(drain, o!());
648        let config = Config {
649            dry_run: false,
650            force: false,
651            base: None,
652            and_rebase: false,
653            whole_file: false,
654            one_fixup_per_commit: false,
655            logger: &logger,
656        };
657        run_with_repo(&config, &ctx.repo).unwrap();
658
659        let mut revwalk = ctx.repo.revwalk().unwrap();
660        revwalk.push_head().unwrap();
661        assert_eq!(revwalk.count(), 2);
662
663        assert!(nothing_left_in_index(&ctx.repo).unwrap());
664    }
665
666    #[test]
667    fn do_not_autostage_if_index_was_not_empty() {
668        let (ctx, file_path) = prepare_repo();
669
670        // enable config var
671        ctx.repo
672            .config()
673            .unwrap()
674            .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
675            .unwrap();
676
677        let (_, fp2) = autostage_common(&ctx, &file_path);
678        // we stage the extra file - should stay in index
679        add(&ctx.repo, &fp2);
680
681        // run 'git-absorb'
682        let drain = slog::Discard;
683        let logger = slog::Logger::root(drain, o!());
684        let config = Config {
685            dry_run: false,
686            force: false,
687            base: None,
688            and_rebase: false,
689            whole_file: false,
690            one_fixup_per_commit: false,
691            logger: &logger,
692        };
693        run_with_repo(&config, &ctx.repo).unwrap();
694
695        let mut revwalk = ctx.repo.revwalk().unwrap();
696        revwalk.push_head().unwrap();
697        assert_eq!(revwalk.count(), 1);
698
699        assert_eq!(index_stats(&ctx.repo).unwrap().files_changed(), 1);
700    }
701
702    #[test]
703    fn do_not_autostage_if_not_enabled_by_config_var() {
704        let (ctx, file_path) = prepare_repo();
705
706        // disable config var
707        ctx.repo
708            .config()
709            .unwrap()
710            .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, false)
711            .unwrap();
712
713        autostage_common(&ctx, &file_path);
714
715        // run 'git-absorb'
716        let drain = slog::Discard;
717        let logger = slog::Logger::root(drain, o!());
718        let config = Config {
719            dry_run: false,
720            force: false,
721            base: None,
722            and_rebase: false,
723            whole_file: false,
724            one_fixup_per_commit: false,
725            logger: &logger,
726        };
727        run_with_repo(&config, &ctx.repo).unwrap();
728
729        let mut revwalk = ctx.repo.revwalk().unwrap();
730        revwalk.push_head().unwrap();
731        assert_eq!(revwalk.count(), 1);
732
733        assert!(nothing_left_in_index(&ctx.repo).unwrap());
734    }
735
736    #[test]
737    fn fixup_message_always_commit_sha_if_configured() {
738        let ctx = prepare_and_stage();
739
740        ctx.repo
741            .config()
742            .unwrap()
743            .set_bool(config::FIXUP_TARGET_ALWAYS_SHA_CONFIG_NAME, true)
744            .unwrap();
745
746        // run 'git-absorb'
747        let drain = slog::Discard;
748        let logger = slog::Logger::root(drain, o!());
749        let config = Config {
750            dry_run: false,
751            force: false,
752            base: None,
753            and_rebase: false,
754            whole_file: false,
755            one_fixup_per_commit: true,
756            logger: &logger,
757        };
758        run_with_repo(&config, &ctx.repo).unwrap();
759        assert!(nothing_left_in_index(&ctx.repo).unwrap());
760
761        let mut revwalk = ctx.repo.revwalk().unwrap();
762        revwalk.push_head().unwrap();
763
764        let oids: Vec<git2::Oid> = revwalk.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
765        assert_eq!(oids.len(), 2);
766
767        let commit = ctx.repo.find_commit(oids[0]).unwrap();
768        let actual_msg = commit.summary().unwrap();
769        let expected_msg = format!("fixup! {}", oids[1]);
770        assert_eq!(actual_msg, expected_msg);
771    }
772}