Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/concepts/commit_parsing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,35 @@ to handle your specific commit message style.

----

.. _commit_parser-reverted_commits:

Reverted Commits
----------------

When determining the next version, PSR ignores an unreleased commit and its
revert when both are in the commits being evaluated for that release. For example,
a breaking change followed by its revert does not require a major release.
Other unreleased changes still determine the version normally.

The revert must use Git's ``Revert "..."`` or ``Reapply "..."`` subject, or a
Conventional Commits ``revert:`` subject (with an optional scope), and include
``This reverts commit <full commit hash>.`` on its own line. Abbreviated commit
hashes and references to commits outside the release's history window are not
paired. If several reverts reference the same commit, their connected revert
history is retained rather than choosing an arbitrary pair. Reverting a revert
restores the original commit's version impact.

This filtering applies before commit parsing, including parsing squashed commit
messages. It does not change forced version bumps or remove historical entries
from the changelog. Reverting an already released change continues to use the
configured commit parser's bump rules.

Git's ``git revert -m`` format for reverting a merge is not paired: a merge's
version impact can include commits from its merged parent history, rather than
only its own message.

----

.. _commit_parser-builtin:

Built-in Commit Parsers
Expand Down
93 changes: 93 additions & 0 deletions src/semantic_release/version/_reverts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from __future__ import annotations

from collections import Counter, defaultdict
from re import MULTILINE, compile as regexp
from typing import TYPE_CHECKING

from semantic_release.commit_parser.util import force_str

if TYPE_CHECKING: # pragma: no cover
from typing import Sequence

from git.objects.commit import Commit


_REVERT_SUBJECT = regexp(
r'^(?:(?:Revert|Reapply) ".+"|revert(?:\([^\n()]+\))?!?:[^\n]+)\r?$'
)
_REVERT_TARGET = regexp(
r"^This reverts commit ([0-9a-f]{40}|[0-9a-f]{64})\.?\r?$", MULTILINE
)


def _find_ambiguous_reverts(targets: dict[str, str]) -> set[str]:
# Multiple reverts of one target may have different parser bump levels.
# Keep their entire connected history, since choosing a pair would otherwise
# make the result depend on traversal order.
reverts_by_target: dict[str, list[str]] = defaultdict(list)
for commit_id, target_id in targets.items():
reverts_by_target[target_id].append(commit_id)
ambiguous: set[str] = set()
pending = [
target_id
for target_id, reverts in reverts_by_target.items()
if len(reverts) > 1
]
while pending:
commit_id = pending.pop()
if commit_id in ambiguous:
continue
ambiguous.add(commit_id)
pending.extend(reverts_by_target.get(commit_id, []))
if commit_id in targets:
pending.append(targets[commit_id])
return ambiguous


def filter_reverted_commits(commits: Sequence[Commit]) -> list[Commit]:
"""
Remove unreleased commit/revert pairs before computing their version impact.

References must name a complete commit ID in the supplied release window.
Process outer reverts before their targets so reverting a revert restores the
original change, regardless of the history traversal order.
"""
commit_ids = {commit.hexsha for commit in commits}
targets: dict[str, str] = {}
for commit in commits:
message = force_str(commit.message)
subject = message.partition("\n")[0]
if not _REVERT_SUBJECT.fullmatch(subject):
continue

references = _REVERT_TARGET.findall(message)
if (
len(references) == 1
and references[0] in commit_ids
and references[0] != commit.hexsha
):
targets[commit.hexsha] = references[0]

ambiguous = _find_ambiguous_reverts(targets)
targets = {
commit_id: target_id
for commit_id, target_id in targets.items()
if commit_id not in ambiguous
}

# An original may be encountered before a later revert along another merge
# parent. Follow the reference graph rather than relying on DFS or timestamps.
referenced_by = Counter(targets.values())
pending = [commit_id for commit_id in targets if not referenced_by[commit_id]]
canceled: set[str] = set()
while pending:
commit_id = pending.pop()
target_id = targets[commit_id]
if commit_id not in canceled and target_id not in canceled:
canceled.update((commit_id, target_id))

referenced_by[target_id] -= 1
if not referenced_by[target_id] and target_id in targets:
pending.append(target_id)

return [commit for commit in commits if commit.hexsha not in canceled]
5 changes: 4 additions & 1 deletion src/semantic_release/version/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from semantic_release.errors import InternalError, InvalidVersion
from semantic_release.globals import logger
from semantic_release.helpers import validate_types_in_sequence
from semantic_release.version._reverts import filter_reverted_commits

if TYPE_CHECKING: # pragma: no cover
from typing import Sequence
Expand Down Expand Up @@ -344,7 +345,9 @@ def next_version(
)

# Step 5. apply the parser to each commit in the history (could return multiple results per commit)
parsed_results = list(map(commit_parser.parse, commits_since_last_release))
parsed_results = list(
map(commit_parser.parse, filter_reverted_commits(commits_since_last_release))
)

# Step 5A. Accumulate all parsed results into a single list accounting for possible multiple results per commit
consolidated_results: list[ParseResult] = reduce(
Expand Down
217 changes: 217 additions & 0 deletions tests/e2e/cmd_version/test_version_reverts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

from semantic_release.hvcs.github import Github

from tests.const import VERSION_SUBCMD
from tests.util import add_text_to_file, assert_successful_exit_code

if TYPE_CHECKING:
from unittest.mock import MagicMock

from requests_mock import Mocker

from tests.conftest import RunCliFn
from tests.fixtures.git_repo import BuiltRepoResult


@pytest.mark.parametrize("conventional_revert", [False, True])
@pytest.mark.parametrize(
"reverts, remaining_fix, expected_version",
[
(1, False, "0.1.1"),
(1, True, "0.1.2"),
(2, False, "1.0.0"),
(3, False, "0.1.1"),
],
)
def test_version_print_unreleased_revert_pairs(
repo_w_trunk_only_conventional_commits: BuiltRepoResult,
conventional_revert: bool,
reverts: int,
remaining_fix: bool,
expected_version: str,
file_in_repo: str,
run_cli: RunCliFn,
mocked_git_push: MagicMock,
post_mocker: Mocker,
):
"""
Given an unreleased breaking change and real Git revert commits,
When determining the next version,
Then canceled pairs do not bump the version, and reverting a revert restores it.
"""
repo = repo_w_trunk_only_conventional_commits["repo"]
add_text_to_file(repo, file_in_repo)
repo.git.commit(
"-am", "feat: change the public API\n\nBREAKING CHANGE: remove the old API"
)

for _ in range(reverts):
target = repo.head.commit.hexsha
repo.git.revert("--no-edit", target)
if conventional_revert:
repo.git.commit(
"--amend",
"-m",
f"revert: undo the previous change\n\nThis reverts commit {target}.",
)

if remaining_fix:
add_text_to_file(repo, file_in_repo)
repo.git.commit("-am", "fix: correct an independent bug")

head_before = repo.head.commit.hexsha
tags_before = {tag.name for tag in repo.tags}
result = run_cli(
[VERSION_SUBCMD, "--print"], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}
)

assert_successful_exit_code(result, [VERSION_SUBCMD, "--print"])
assert result.stdout == f"{expected_version}\n"
assert repo.head.commit.hexsha == head_before
assert {tag.name for tag in repo.tags} == tags_before
assert mocked_git_push.call_count == 0
assert post_mocker.call_count == 0


@pytest.mark.parametrize("release_target", [False, True])
def test_version_revert_respects_release_boundary_and_forced_bump(
repo_w_trunk_only_conventional_commits: BuiltRepoResult,
release_target: bool,
file_in_repo: str,
run_cli: RunCliFn,
mocked_git_push: MagicMock,
post_mocker: Mocker,
):
"""
Given a real revert and optionally a release containing its original change,
When printing a forced patch version,
Then the existing release boundary and forced bump are preserved.
"""
repo = repo_w_trunk_only_conventional_commits["repo"]
add_text_to_file(repo, file_in_repo)
repo.git.commit("-am", "feat: introduce a feature")
if release_target:
repo.create_tag("v0.2.0", message="Release 0.2.0")
repo.git.revert("--no-edit", "HEAD")

head_before = repo.head.commit.hexsha
tags_before = {tag.name for tag in repo.tags}
args = [VERSION_SUBCMD, "--print", "--patch"]
result = run_cli(args, env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"})

assert_successful_exit_code(result, args)
assert result.stdout == ("0.2.1\n" if release_target else "0.1.2\n")
assert repo.head.commit.hexsha == head_before
assert {tag.name for tag in repo.tags} == tags_before
assert mocked_git_push.call_count == 0
assert post_mocker.call_count == 0


def test_version_does_not_release_canceled_change(
repo_w_trunk_only_conventional_commits: BuiltRepoResult,
file_in_repo: str,
run_cli: RunCliFn,
mocked_git_push: MagicMock,
post_mocker: Mocker,
):
"""
Given a breaking change canceled before release,
When running the version command,
Then no version commit, tag, push, or hosted release is created.
"""
repo = repo_w_trunk_only_conventional_commits["repo"]
add_text_to_file(repo, file_in_repo)
repo.git.commit("-am", "feat: change API\n\nBREAKING CHANGE: remove API")
repo.git.revert("--no-edit", "HEAD")
head_before = repo.head.commit.hexsha
tags_before = {tag.name for tag in repo.tags}
status_before = repo.git.status(short=True)

args = [VERSION_SUBCMD, "--no-push", "--no-vcs-release"]
result = run_cli(args, env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"})

assert_successful_exit_code(result, args)
assert repo.head.commit.hexsha == head_before
assert {tag.name for tag in repo.tags} == tags_before
assert repo.git.status(short=True) == status_before
assert mocked_git_push.call_count == 0
assert post_mocker.call_count == 0


def test_reverting_released_feature_keeps_configured_bump(
repo_w_trunk_only_conventional_commits: BuiltRepoResult,
file_in_repo: str,
run_cli: RunCliFn,
mocked_git_push: MagicMock,
post_mocker: Mocker,
):
"""
Given a revert of an already released feature with a patch commit message,
When printing the automatically determined next version,
Then the configured patch bump is preserved.
"""
repo = repo_w_trunk_only_conventional_commits["repo"]
add_text_to_file(repo, file_in_repo)
repo.git.commit("-am", "feat: introduce a released feature")
target = repo.head.commit.hexsha
repo.create_tag("v0.2.0", message="Release 0.2.0")
repo.git.revert("--no-edit", target)
repo.git.commit(
"--amend",
"-m",
f"fix: revert released feature\n\nThis reverts commit {target}.",
)

args = [VERSION_SUBCMD, "--print"]
result = run_cli(args, env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"})

assert_successful_exit_code(result, args)
assert result.stdout == "0.2.1\n"
assert mocked_git_push.call_count == 0
assert post_mocker.call_count == 0


@pytest.mark.parametrize("squashed", [False, True])
def test_version_revert_through_merge_history(
repo_w_trunk_only_conventional_commits: BuiltRepoResult,
squashed: bool,
file_in_repo: str,
run_cli: RunCliFn,
mocked_git_push: MagicMock,
post_mocker: Mocker,
):
"""
Given a reverted breaking change on a branch merged alongside a fix,
When printing the next version,
Then the remaining fix determines the release even across merged history.
"""
repo = repo_w_trunk_only_conventional_commits["repo"]
main_branch = repo.active_branch.name
repo.git.checkout("-b", "reverted-feature")
add_text_to_file(repo, file_in_repo)
message = (
"feat: change public API\n\nBREAKING CHANGE: remove API"
if not squashed
else "feat: change public API (#123)\n\n"
"* feat: add another feature\n\n"
"* fix: fix another issue\n\nBREAKING CHANGE: remove API"
)
repo.git.commit("-am", message)
repo.git.revert("--no-edit", "HEAD")
repo.git.checkout(main_branch)
add_text_to_file(repo, file_in_repo)
repo.git.commit("-am", "fix: correct another problem")
repo.git.merge("--no-ff", "-m", "Merge reverted feature", "reverted-feature")

args = [VERSION_SUBCMD, "--print"]
result = run_cli(args, env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"})

assert_successful_exit_code(result, args)
assert result.stdout == "0.1.2\n"
assert mocked_git_push.call_count == 0
assert post_mocker.call_count == 0
Loading
Loading