From 9c98974302af169cd1e5722091543a4ac8968b5f Mon Sep 17 00:00:00 2001 From: Jakob Zimmermann Date: Sun, 7 Dec 2025 14:56:32 +0100 Subject: [PATCH 1/4] fix(changelog_merge_prerelease): changelog not merged during cz bump --- commitizen/changelog.py | 18 ++++++++++++++++-- commitizen/changelog_formats/__init__.py | 8 +++++++- commitizen/changelog_formats/base.py | 24 +++++++++++++++++++++++- commitizen/commands/bump.py | 2 ++ commitizen/commands/changelog.py | 14 ++++++++++++++ 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/commitizen/changelog.py b/commitizen/changelog.py index bdf11326b..86b7f96f8 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -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) @@ -86,6 +97,7 @@ 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) @@ -93,8 +105,10 @@ def generate_tree_from_commits( 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 "" diff --git a/commitizen/changelog_formats/__init__.py b/commitizen/changelog_formats/__init__.py index 9a5eea7ab..453b63945 100644 --- a/commitizen/changelog_formats/__init__.py +++ b/commitizen/changelog_formats/__init__.py @@ -8,7 +8,7 @@ else: import importlib_metadata as metadata -from commitizen.changelog import Metadata +from commitizen.changelog import IncrementalMergeInfo, Metadata from commitizen.config.base_config import BaseConfig from commitizen.exceptions import ChangelogFormatUnknown @@ -48,6 +48,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() diff --git a/commitizen/changelog_formats/base.py b/commitizen/changelog_formats/base.py index 64a795207..e9476c79e 100644 --- a/commitizen/changelog_formats/base.py +++ b/commitizen/changelog_formats/base.py @@ -4,8 +4,9 @@ from abc import ABCMeta from typing import IO, 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 @@ -69,6 +70,27 @@ def get_metadata_from_file(self, file: IO[Any]) -> Metadata: 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: + for index, line in enumerate(file): + line = line.strip().lower() + + parsed = self.parse_version_from_title(line) + if parsed: + if not self.tag_rules.extract_version( + GitTag(parsed.tag, "", "") + ).is_prerelease: + return IncrementalMergeInfo(name=parsed.tag, index=index) + return IncrementalMergeInfo() + def parse_version_from_title(self, line: str) -> VersionTag | None: """ Extract the version from a title line if any diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 3dc678920..128a45d61 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -311,6 +311,8 @@ def __call__(self) -> None: "extras": self.extras, "incremental": True, "dry_run": dry_run, + "during_version_bump": prerelease + is None, # We let the changelog implementation know that we want to replace prereleases while staying incremental AND the new tag does not exist already } if self.changelog_to_stdout: changelog_cmd = Changelog(self.config, {**args, "dry_run": True}) # type: ignore[typeddict-item] diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 27b8ccb25..b5d5af25b 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -41,6 +41,7 @@ class ChangelogArgs(TypedDict, total=False): template: str extras: dict[str, Any] export_template: str + during_version_bump: bool | None class Changelog: @@ -121,6 +122,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'. @@ -218,6 +221,16 @@ 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 + ) + start_rev = latest_full_release_info.name or "" + 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 + 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 @@ -234,6 +247,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( From 851663543b5ce9e5c15209d762cc4069a8f9cb63 Mon Sep 17 00:00:00 2001 From: Jakob Zimmermann Date: Sun, 7 Dec 2025 14:58:59 +0100 Subject: [PATCH 2/4] test(changelog_merge_prerelease): add tests for changelog_merge_prerelease during bump --- tests/commands/test_bump_command.py | 71 +++++++++++++++++++ ...log_config_flag_merge_prerelease_alpha_.md | 11 +++ ...elog_config_flag_merge_prerelease_beta_.md | 11 +++ ...ag_merge_prerelease_more_commits_alpha_.md | 15 ++++ ...lag_merge_prerelease_more_commits_beta_.md | 15 ++++ ..._flag_merge_prerelease_more_commits_rc_.md | 15 ++++ ...ngelog_config_flag_merge_prerelease_rc_.md | 11 +++ 7 files changed, 149 insertions(+) create mode 100644 tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_alpha_.md create mode 100644 tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_beta_.md create mode 100644 tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_alpha_.md create mode 100644 tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_beta_.md create mode 100644 tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_rc_.md create mode 100644 tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_rc_.md diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 59297b172..8ed83940b 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1737,3 +1737,74 @@ 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 + print(out) + + 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_more_commits( + 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") + 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 + print(out) + + file_regression.check(out, extension=".md") diff --git a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_alpha_.md b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_alpha_.md new file mode 100644 index 000000000..ce5c68708 --- /dev/null +++ b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_alpha_.md @@ -0,0 +1,11 @@ +## 0.2.0 + +### Feat + +- add new output + +### Fix + +- output glitch + +## 0.1.0 diff --git a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_beta_.md b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_beta_.md new file mode 100644 index 000000000..ce5c68708 --- /dev/null +++ b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_beta_.md @@ -0,0 +1,11 @@ +## 0.2.0 + +### Feat + +- add new output + +### Fix + +- output glitch + +## 0.1.0 diff --git a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_alpha_.md b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_alpha_.md new file mode 100644 index 000000000..29013670a --- /dev/null +++ b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_alpha_.md @@ -0,0 +1,15 @@ +## 0.2.0 + +### Feat + +- add new output + +### Fix + +- output glitch + +## 0.1.0 + +### Feat + +- more relevant commit diff --git a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_beta_.md b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_beta_.md new file mode 100644 index 000000000..29013670a --- /dev/null +++ b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_beta_.md @@ -0,0 +1,15 @@ +## 0.2.0 + +### Feat + +- add new output + +### Fix + +- output glitch + +## 0.1.0 + +### Feat + +- more relevant commit diff --git a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_rc_.md b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_rc_.md new file mode 100644 index 000000000..29013670a --- /dev/null +++ b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_rc_.md @@ -0,0 +1,15 @@ +## 0.2.0 + +### Feat + +- add new output + +### Fix + +- output glitch + +## 0.1.0 + +### Feat + +- more relevant commit diff --git a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_rc_.md b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_rc_.md new file mode 100644 index 000000000..ce5c68708 --- /dev/null +++ b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_rc_.md @@ -0,0 +1,11 @@ +## 0.2.0 + +### Feat + +- add new output + +### Fix + +- output glitch + +## 0.1.0 From 938f66a7e8656c1e84262de9acfe30da41743687 Mon Sep 17 00:00:00 2001 From: Jakob Zimmermann Date: Tue, 9 Dec 2025 18:20:27 +0100 Subject: [PATCH 3/4] test(test_bump_command.py): cleanup and fix tests for changelog_merge_prerelease --- tests/commands/test_bump_command.py | 13 ++++++------- ...ge_prerelease_only_prerelease_present_alpha_.md} | 7 +------ ...rge_prerelease_only_prerelease_present_beta_.md} | 7 +------ ...merge_prerelease_only_prerelease_present_rc_.md} | 7 +------ 4 files changed, 9 insertions(+), 25 deletions(-) rename tests/commands/test_bump_command/{test_changelog_config_flag_merge_prerelease_more_commits_alpha_.md => test_changelog_config_flag_merge_prerelease_only_prerelease_present_alpha_.md} (80%) rename tests/commands/test_bump_command/{test_changelog_config_flag_merge_prerelease_more_commits_beta_.md => test_changelog_config_flag_merge_prerelease_only_prerelease_present_beta_.md} (80%) rename tests/commands/test_bump_command/{test_changelog_config_flag_merge_prerelease_more_commits_rc_.md => test_changelog_config_flag_merge_prerelease_only_prerelease_present_rc_.md} (80%) diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 8ed83940b..16bd269af 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1767,16 +1767,15 @@ def test_changelog_config_flag_merge_prerelease( with open(changelog_path) as f: out = f.read() out = re.sub( - r"\([^)]*\)", "", out + r" \([^)]*\)", "", out ) # remove date from release, since I have no idea how to mock that - print(out) 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_more_commits( +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 @@ -1786,8 +1785,9 @@ def test_changelog_config_flag_merge_prerelease_more_commits( f.write("annotated_tag = true\n") create_file_and_commit("feat: more relevant commit") - mocker.patch("commitizen.git.GitTag.date", "1970-01-01") - git.tag("0.1.0") + 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") @@ -1803,8 +1803,7 @@ def test_changelog_config_flag_merge_prerelease_more_commits( with open(changelog_path) as f: out = f.read() out = re.sub( - r"\([^)]*\)", "", out + r" \([^)]*\)", "", out ) # remove date from release, since I have no idea how to mock that - print(out) file_regression.check(out, extension=".md") diff --git a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_alpha_.md b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_only_prerelease_present_alpha_.md similarity index 80% rename from tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_alpha_.md rename to tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_only_prerelease_present_alpha_.md index 29013670a..ce0455768 100644 --- a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_alpha_.md +++ b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_only_prerelease_present_alpha_.md @@ -3,13 +3,8 @@ ### Feat - add new output +- more relevant commit ### Fix - output glitch - -## 0.1.0 - -### Feat - -- more relevant commit diff --git a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_beta_.md b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_only_prerelease_present_beta_.md similarity index 80% rename from tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_beta_.md rename to tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_only_prerelease_present_beta_.md index 29013670a..ce0455768 100644 --- a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_beta_.md +++ b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_only_prerelease_present_beta_.md @@ -3,13 +3,8 @@ ### Feat - add new output +- more relevant commit ### Fix - output glitch - -## 0.1.0 - -### Feat - -- more relevant commit diff --git a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_rc_.md b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_only_prerelease_present_rc_.md similarity index 80% rename from tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_rc_.md rename to tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_only_prerelease_present_rc_.md index 29013670a..ce0455768 100644 --- a/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_more_commits_rc_.md +++ b/tests/commands/test_bump_command/test_changelog_config_flag_merge_prerelease_only_prerelease_present_rc_.md @@ -3,13 +3,8 @@ ### Feat - add new output +- more relevant commit ### Fix - output glitch - -## 0.1.0 - -### Feat - -- more relevant commit From 73aa8a342d64029a6c8c97c7bb2ea291af38ce62 Mon Sep 17 00:00:00 2001 From: Jakob Zimmermann Date: Tue, 9 Dec 2025 18:21:42 +0100 Subject: [PATCH 4/4] fix(changelog): fix merge_prerelease flag not working if changelog contains only prereleases --- commitizen/changelog_formats/base.py | 4 +++- commitizen/commands/changelog.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/commitizen/changelog_formats/base.py b/commitizen/changelog_formats/base.py index e9476c79e..97bd35c8c 100644 --- a/commitizen/changelog_formats/base.py +++ b/commitizen/changelog_formats/base.py @@ -80,7 +80,9 @@ def get_latest_full_release(self, filepath: str) -> IncrementalMergeInfo: 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 = self.parse_version_from_title(line) @@ -89,7 +91,7 @@ def get_latest_full_release_from_file(self, file: IO[Any]) -> IncrementalMergeIn GitTag(parsed.tag, "", "") ).is_prerelease: return IncrementalMergeInfo(name=parsed.tag, index=index) - return IncrementalMergeInfo() + return IncrementalMergeInfo(index=latest_version_index) def parse_version_from_title(self, line: str) -> VersionTag | None: """ diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index b5d5af25b..ff2416a8e 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -225,12 +225,17 @@ def __call__(self) -> None: latest_full_release_info = self.changelog_format.get_latest_full_release( self.file_name ) - start_rev = latest_full_release_info.name or "" 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