diff --git a/gitrevise/todo.py b/gitrevise/todo.py index ad04e4a..426265e 100644 --- a/gitrevise/todo.py +++ b/gitrevise/todo.py @@ -1,6 +1,6 @@ import re from enum import Enum -from typing import List, Optional +from typing import List, Optional, Tuple from .odb import Commit, Repository, MissingObject from .utils import run_editor, run_sequence_editor, edit_commit_message, cut_commit @@ -240,19 +240,58 @@ def edit_todos( return result +def is_fixup(todo: Step) -> bool: + return todo.kind in (StepKind.FIXUP, StepKind.SQUASH) + + +def squash_message_template( + target_message: bytes, fixups: List[Tuple[StepKind, bytes]] +) -> bytes: + fused = ( + b"# This is a combination of %d commits.\n" % (len(fixups) + 1) + + b"# This is the 1st commit message:\n" + + b"\n" + + target_message + ) + + for i, (kind, message) in enumerate(fixups): + fused += b"\n" + if kind == StepKind.FIXUP: + fused += ( + b"# The commit message #%d will be skipped:\n" % (i + 2) + + b"\n" + + b"".join(b"# " + line for line in message.splitlines(keepends=True)) + ) + else: + assert kind == StepKind.SQUASH + fused += b"# This is the commit message #%d:\n\n%s" % (i + 2, message) + + return fused + + def apply_todos(current: Commit, todos: List[Step], reauthor: bool = False) -> Commit: - for step in todos: + fixups: List[Tuple[StepKind, bytes]] = [] + + for i, step in enumerate(todos): rebased = step.commit.rebase(current).update(message=step.message) if step.kind == StepKind.PICK: current = rebased - elif step.kind == StepKind.FIXUP: + elif is_fixup(step): + if not fixups: + fixup_target_message = current.message + fixups.append((step.kind, rebased.message)) current = current.update(tree=rebased.tree()) + is_last_fixup = i + 1 == len(todos) or not is_fixup(todos[i + 1]) + if is_last_fixup and any( + kind == StepKind.SQUASH for kind, message in fixups + ): + current = current.update( + message=squash_message_template(fixup_target_message, fixups) + ) + current = edit_commit_message(current) + fixups.clear() elif step.kind == StepKind.REWORD: current = edit_commit_message(rebased) - elif step.kind == StepKind.SQUASH: - fused = current.message + b"\n\n" + rebased.message - current = current.update(tree=rebased.tree(), message=fused) - current = edit_commit_message(current) elif step.kind == StepKind.CUT: current = cut_commit(rebased) elif step.kind == StepKind.INDEX: diff --git a/tests/test_fixup.py b/tests/test_fixup.py index d0b84d8..d3314e6 100644 --- a/tests/test_fixup.py +++ b/tests/test_fixup.py @@ -375,3 +375,90 @@ def test_autosquash_multiline_summary(repo): new = repo.get_commit("HEAD") assert old != new, "commit was modified" assert old.parents() == new.parents(), "parents are unchanged" + + +def test_autosquash_multiple_squashes(repo): + bash( + """ + git commit --allow-empty -m 'initial commit' + git commit --allow-empty -m 'target' + git commit --allow-empty --squash :/^target --no-edit + git commit --allow-empty --squash :/^target --no-edit + """ + ) + + with editor_main([":/^init", "--autosquash"], input=b"") as ed: + with ed.next_file() as f: + assert f.startswith_dedent( + """\ + # This is a combination of 3 commits. + # This is the 1st commit message: + + target + + # This is the commit message #2: + + squash! target + + # This is the commit message #3: + + squash! target + """ + ) + f.replace_dedent("two squashes") + + +def test_autosquash_multiple_squashes_with_fixup(repo): + bash( + """ + git commit --allow-empty -m 'initial commit' + git commit --allow-empty -m 'target' + git commit --allow-empty --squash :/^target --no-edit + git commit --allow-empty --fixup :/^target --no-edit + git commit --allow-empty --squash :/^target --no-edit + git commit --allow-empty -m 'unrelated' + """ + ) + with editor_main([":/^init", "--autosquash"], input=b"") as ed: + with ed.next_file() as f: + assert f.startswith_dedent( + """\ + # This is a combination of 4 commits. + # This is the 1st commit message: + + target + + # This is the commit message #2: + + squash! target + + # The commit message #3 will be skipped: + + # fixup! target + + # This is the commit message #4: + + squash! target + """ + ) + f.replace_dedent("squashes + fixup") + + +def test_autosquash_multiple_independent_squashes(repo): + bash( + """ + git commit --allow-empty -m 'initial commit' + git commit --allow-empty -m 'target1' + git commit --allow-empty -m 'target2' + git commit --allow-empty --squash :/^target1 --no-edit + git commit --allow-empty --squash :/^target2 --no-edit + """ + ) + + with editor_main([":/^init", "--autosquash"], input=b"") as ed: + with ed.next_file() as f: + assert f.startswith_dedent("# This is a combination of 2 commits.") + f.replace_dedent("squash 1") + with ed.next_file() as f: + assert f.startswith_dedent("# This is a combination of 2 commits.") + f.replace_dedent("squash 2")