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
18 changes: 16 additions & 2 deletions commitizen/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ def __post_init__(self) -> None:
self.latest_version_tag = self.latest_version


@dataclass
class IncrementalMergeInfo:
"""
Information regarding the last non-pre-release, parsed from the changelog. Required to merge pre-releases on bump.
Separate from Metadata to not mess with the interface.
"""

name: str | None = None
index: int | None = None


def get_commit_tag(commit: GitCommit, tags: list[GitTag]) -> GitTag | None:
return next((tag for tag in tags if tag.rev == commit.rev), None)

Expand All @@ -86,15 +97,18 @@ def generate_tree_from_commits(
changelog_message_builder_hook: MessageBuilderHook | None = None,
changelog_release_hook: ChangelogReleaseHook | None = None,
rules: TagRules | None = None,
during_version_bump: bool = False,
) -> Generator[dict[str, Any], None, None]:
pat = re.compile(changelog_pattern)
map_pat = re.compile(commit_parser, re.MULTILINE)
body_map_pat = re.compile(commit_parser, re.MULTILINE | re.DOTALL)
rules = rules or TagRules()

# Check if the latest commit is not tagged

current_tag = get_commit_tag(commits[0], tags) if commits else None
if during_version_bump and rules.merge_prereleases:
current_tag = None
else:
current_tag = get_commit_tag(commits[0], tags) if commits else None
Comment on lines +108 to +111
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if during_version_bump and rules.merge_prereleases:
current_tag = None
else:
current_tag = get_commit_tag(commits[0], tags) if commits else None
if during_version_bump and rules.merge_prereleases and not commits:
current_tag = None
else:
# Check if the latest commit is not tagged
current_tag = get_commit_tag(commits[0], tags)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be or not commits, in which case I find it more readable as is.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thanks

current_tag_name = unreleased_version or "Unreleased"
current_tag_date = (
date.today().isoformat() if unreleased_version is not None else ""
Expand Down
8 changes: 7 additions & 1 deletion commitizen/changelog_formats/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
if TYPE_CHECKING:
from collections.abc import Callable

from commitizen.changelog import Metadata
from commitizen.changelog import IncrementalMergeInfo, Metadata
from commitizen.config.base_config import BaseConfig

CHANGELOG_FORMAT_ENTRYPOINT = "commitizen.changelog_format"
Expand Down Expand Up @@ -47,6 +47,12 @@ def get_metadata(self, filepath: str) -> Metadata:
"""
raise NotImplementedError

def get_latest_full_release(self, filepath: str) -> IncrementalMergeInfo:
"""
Extract metadata for the last non-pre-release.
"""
raise NotImplementedError


KNOWN_CHANGELOG_FORMATS: dict[str, type[ChangelogFormat]] = {
ep.name: ep.load()
Expand Down
37 changes: 32 additions & 5 deletions commitizen/changelog_formats/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
from abc import ABCMeta
from typing import IO, TYPE_CHECKING, Any, ClassVar

from commitizen.changelog import Metadata
from commitizen.changelog import IncrementalMergeInfo, Metadata
from commitizen.config.base_config import BaseConfig
from commitizen.git import GitTag
from commitizen.tags import TagRules, VersionTag
from commitizen.version_schemes import get_version_scheme

Expand Down Expand Up @@ -60,17 +62,42 @@ def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
meta.unreleased_end = index

# Try to find the latest release done
parsed = self.parse_version_from_title(line)
if parsed:
meta.latest_version = parsed.version
meta.latest_version_tag = parsed.tag
parsed_version = self.parse_version_from_title(line)
if parsed_version:
meta.latest_version = parsed_version.version
meta.latest_version_tag = parsed_version.tag
meta.latest_version_position = index
break # there's no need for more info
if meta.unreleased_start is not None and meta.unreleased_end is None:
meta.unreleased_end = index

return meta

def get_latest_full_release(self, filepath: str) -> IncrementalMergeInfo:
if not os.path.isfile(filepath):
return IncrementalMergeInfo()

with open(
filepath, encoding=self.config.settings["encoding"]
) as changelog_file:
return self.get_latest_full_release_from_file(changelog_file)

def get_latest_full_release_from_file(self, file: IO[Any]) -> IncrementalMergeInfo:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why extract this function? I don't see any benefits.

You could put the whole function body under with open block and the logic is still clear.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I followed the same pattern as with metadata, which also has an interface calling the get function, which in turn calls the get_from_file function. I think both works, but I would prefer to leave it as is to stay consistent.

latest_version_index: int | None = None
for index, line in enumerate(file):
latest_version_index = index
line = line.strip().lower()

parsed_version = self.parse_version_from_title(line)
if (
parsed_version
and not self.tag_rules.extract_version(
GitTag(parsed_version.tag, "", "")
).is_prerelease
):
return IncrementalMergeInfo(name=parsed_version.tag, index=index)
return IncrementalMergeInfo(index=latest_version_index)

def parse_version_from_title(self, line: str) -> VersionTag | None:
"""
Extract the version from a title line if any
Expand Down
2 changes: 2 additions & 0 deletions commitizen/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ def __call__(self) -> None:
"extras": self.extras,
"incremental": True,
"dry_run": dry_run,
"during_version_bump": self.arguments["prerelease"]
is None, # governs logic for merge_prerelease
}
if self.changelog_to_stdout:
changelog_cmd = Changelog(
Expand Down
19 changes: 19 additions & 0 deletions commitizen/commands/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class ChangelogArgs(TypedDict, total=False):
template: str
extras: dict[str, Any]
export_template: str
during_version_bump: bool | None


class Changelog:
Expand Down Expand Up @@ -124,6 +125,8 @@ def __init__(self, config: BaseConfig, arguments: ChangelogArgs) -> None:
self.extras = arguments.get("extras") or {}
self.export_template_to = arguments.get("export_template")

self.during_version_bump: bool = arguments.get("during_version_bump") or False

def _find_incremental_rev(self, latest_version: str, tags: Iterable[GitTag]) -> str:
"""Try to find the 'start_rev'.

Expand Down Expand Up @@ -222,6 +225,21 @@ def __call__(self) -> None:
self.tag_rules,
)

if self.during_version_bump and self.tag_rules.merge_prereleases:
latest_full_release_info = self.changelog_format.get_latest_full_release(
self.file_name
)
if latest_full_release_info.index:
changelog_meta.unreleased_start = 0
changelog_meta.latest_version_position = latest_full_release_info.index
changelog_meta.unreleased_end = latest_full_release_info.index - 1

start_rev = latest_full_release_info.name or ""
if not start_rev and latest_full_release_info.index:
# Only pre-releases in changelog
changelog_meta.latest_version_position = None
changelog_meta.unreleased_end = latest_full_release_info.index + 1

commits = git.get_commits(start=start_rev, end=end_rev, args="--topo-order")
if not commits and (
self.current_version is None or not self.current_version.is_prerelease
Expand All @@ -238,6 +256,7 @@ def __call__(self) -> None:
changelog_message_builder_hook=self.cz.changelog_message_builder_hook,
changelog_release_hook=self.cz.changelog_release_hook,
rules=self.tag_rules,
during_version_bump=self.during_version_bump,
)
if self.change_type_order:
tree = changelog.generate_ordered_changelog_tree(
Expand Down
64 changes: 64 additions & 0 deletions tests/commands/test_bump_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from unittest.mock import MagicMock, call

import pytest
from freezegun import freeze_time

import commitizen.commands.bump as bump
from commitizen import cli, cmd, defaults, git, hooks
Expand Down Expand Up @@ -1705,3 +1706,66 @@ def test_is_initial_tag(mocker: MockFixture, tmp_commitizen_project):
# Test case 4: No current tag, user denies
mocker.patch("questionary.confirm", return_value=mocker.Mock(ask=lambda: False))
assert bump_cmd._is_initial_tag(None, is_yes=False) is False


@pytest.mark.parametrize("test_input", ["rc", "alpha", "beta"])
@pytest.mark.usefixtures("tmp_commitizen_project")
@freeze_time("2025-01-01")
def test_changelog_config_flag_merge_prerelease(
mocker: MockFixture, changelog_path, config_path, file_regression, test_input
):
with open(config_path, "a") as f:
f.write("changelog_merge_prerelease = true\n")
f.write("update_changelog_on_bump = true\n")
f.write("annotated_tag = true\n")

create_file_and_commit("irrelevant commit")
mocker.patch("commitizen.git.GitTag.date", "1970-01-01")
git.tag("0.1.0")

create_file_and_commit("feat: add new output")
create_file_and_commit("fix: output glitch")
testargs = ["cz", "bump", "--prerelease", test_input, "--yes"]
mocker.patch.object(sys, "argv", testargs)
cli.main()

testargs = ["cz", "bump", "--changelog"]
mocker.patch.object(sys, "argv", testargs)
cli.main()

with open(changelog_path) as f:
out = f.read()

file_regression.check(out, extension=".md")


@pytest.mark.parametrize("test_input", ["rc", "alpha", "beta"])
@pytest.mark.usefixtures("tmp_commitizen_project")
@freeze_time("2025-01-01")
def test_changelog_config_flag_merge_prerelease_only_prerelease_present(
mocker: MockFixture, changelog_path, config_path, file_regression, test_input
):
with open(config_path, "a") as f:
f.write("changelog_merge_prerelease = true\n")
f.write("update_changelog_on_bump = true\n")
f.write("annotated_tag = true\n")

create_file_and_commit("feat: more relevant commit")
testargs = ["cz", "bump", "--prerelease", test_input, "--yes"]
mocker.patch.object(sys, "argv", testargs)
cli.main()

create_file_and_commit("feat: add new output")
create_file_and_commit("fix: output glitch")
testargs = ["cz", "bump", "--prerelease", test_input, "--yes"]
mocker.patch.object(sys, "argv", testargs)
cli.main()

testargs = ["cz", "bump", "--changelog"]
mocker.patch.object(sys, "argv", testargs)
cli.main()

with open(changelog_path) as f:
out = f.read()

file_regression.check(out, extension=".md")
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## 0.2.0 (2025-01-01)

### Feat

- add new output

### Fix

- output glitch

## 0.1.0 (1970-01-01)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## 0.2.0 (2025-01-01)

### Feat

- add new output

### Fix

- output glitch

## 0.1.0 (1970-01-01)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## 0.2.0 (2025-01-01)

### Feat

- add new output
- more relevant commit

### Fix

- output glitch
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## 0.2.0 (2025-01-01)

### Feat

- add new output
- more relevant commit

### Fix

- output glitch
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## 0.2.0 (2025-01-01)

### Feat

- add new output
- more relevant commit

### Fix

- output glitch
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## 0.2.0 (2025-01-01)

### Feat

- add new output

### Fix

- output glitch

## 0.1.0 (1970-01-01)
6 changes: 5 additions & 1 deletion tests/test_changelog_format_asciidoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest

from commitizen.changelog import Metadata
from commitizen.changelog import IncrementalMergeInfo, Metadata
from commitizen.changelog_formats.asciidoc import AsciiDoc

if TYPE_CHECKING:
Expand Down Expand Up @@ -173,6 +173,10 @@ def test_get_metadata(
assert format.get_metadata(str(changelog)) == expected


def test_get_latest_full_release_no_file(format: AsciiDoc):
assert format.get_latest_full_release("/nonexistent") == IncrementalMergeInfo()


@pytest.mark.parametrize(
"format_with_tags, tag_string, expected, ",
(
Expand Down