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