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
20 changes: 18 additions & 2 deletions commitizen/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ 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 +99,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
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:
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,
# governs logic for merge_prerelease
"during_version_bump": self.arguments["prerelease"] is None,
}
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
63 changes: 63 additions & 0 deletions tests/commands/test_bump_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -1705,3 +1705,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")
@pytest.mark.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")
@pytest.mark.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)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Quick question, is this expected?

Copy link
Author

Choose a reason for hiding this comment

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

I assume you mean the empty version 0.1.0?

I would say yes. Reason is that an annotated tag is created on a version that has no eligible commits e.g. git commit -m "irrelevant commit" (see testcase). The Changelog command renders the release in that case as empty.
If this is not intended, I can modify the testcase to make it look 'more pretty' and open a new issue describing this bug. But I think it works as intended.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks for detailed explanation. I don't think it's a blocker.

@woile wdyt

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, this is how it works. The version tagged has no elegible commits, so the changelog creates an empty entry.

I don't expect this to change, users can go and fix it themselves, as commitizen does incremental updates to the changelog. This scenario would probably happen in a project which is not using conventional commits (or other set of rules), and it starts using it. Or if a mistake is made, but maintainers of a project can still update the changelog manually

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