Skip to content
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
9 changes: 8 additions & 1 deletion commitizen/changelog_formats/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
else:
import importlib_metadata as metadata

from commitizen.config.base_config import BaseConfig
from commitizen.exceptions import ChangelogFormatUnknown

if TYPE_CHECKING:
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 @@ -50,6 +51,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
68 changes: 68 additions & 0 deletions tests/commands/test_bump_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -1705,3 +1705,71 @@ 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")
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()
out = re.sub(
r" \([^)]*\)", "", out
) # remove date from release, since I have no idea how to mock that

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


@pytest.mark.parametrize("test_input", ["rc", "alpha", "beta"])
@pytest.mark.usefixtures("tmp_commitizen_project")
def test_changelog_config_flag_merge_prerelease_only_prerelease_present(
mocker: MockFixture, changelog_path, config_path, file_regression, test_input
):
# supposed to verify that logic regarding indexes is generic
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()
out = re.sub(
r" \([^)]*\)", "", out
) # remove date from release, since I have no idea how to mock that

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

### Feat

- add new output

### Fix

- output glitch

## 0.1.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## 0.2.0

### Feat

- add new output

### Fix

- output glitch

## 0.1.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## 0.2.0

### 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

### 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

### 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

### Feat

- add new output

### Fix

- output glitch

## 0.1.0
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