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