diff --git a/commitizen/changelog.py b/commitizen/changelog.py index 704efe6071..2844c18c44 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -32,7 +32,7 @@ from collections.abc import Iterable from dataclasses import dataclass from datetime import date -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from jinja2 import ( BaseLoader, @@ -74,6 +74,23 @@ def get_commit_tag(commit: GitCommit, tags: list[GitTag]) -> GitTag | None: return next((tag for tag in tags if tag.rev == commit.rev), None) +def _get_release_info( + prev_tag_name: str, + prev_tag_date: str, + changes: dict[str | None, list], + changelog_release_hook: ChangelogReleaseHook | None, + commit_tag: GitTag | None, +) -> dict[str, Any]: + release = { + "version": prev_tag_name, + "date": prev_tag_date, + "changes": changes, + } + if changelog_release_hook: + return changelog_release_hook(release, commit_tag) + return release + + def generate_tree_from_commits( commits: list[GitCommit], tags: list[GitTag], @@ -88,47 +105,44 @@ def generate_tree_from_commits( pat = re.compile(changelog_pattern) map_pat = re.compile(commit_parser, re.MULTILINE) body_map_pat = re.compile(commit_parser, re.MULTILINE | re.DOTALL) - current_tag: GitTag | None = None rules = rules or TagRules() + used_tags: set[GitTag] = set() + prev_tag_name = unreleased_version or "Unreleased" + prev_tag_date = date.today().isoformat() if unreleased_version is not None else "" + # Check if the latest commit is not tagged - if commits: - latest_commit = commits[0] - current_tag = get_commit_tag(latest_commit, tags) - - current_tag_name: str = unreleased_version or "Unreleased" - current_tag_date: str = "" - if unreleased_version is not None: - current_tag_date = date.today().isoformat() - if current_tag is not None and current_tag.name: - current_tag_name = current_tag.name - current_tag_date = current_tag.date - - changes: dict = defaultdict(list) - used_tags: list = [current_tag] + current_tag = get_commit_tag(commits[0], tags) if commits else None + if current_tag is not None: + used_tags.add(current_tag) + if current_tag.name: + prev_tag_name = current_tag.name + prev_tag_date = current_tag.date + + changes: defaultdict[str | None, list] = defaultdict(list) for commit in commits: - commit_tag = get_commit_tag(commit, tags) + current_tag = get_commit_tag(commit, tags) if ( - commit_tag - and commit_tag not in used_tags - and rules.include_in_changelog(commit_tag) + current_tag + and current_tag not in used_tags + and rules.include_in_changelog(current_tag) ): - used_tags.append(commit_tag) - release = { - "version": current_tag_name, - "date": current_tag_date, - "changes": changes, - } - if changelog_release_hook: - release = changelog_release_hook(release, commit_tag) - yield release - current_tag_name = commit_tag.name - current_tag_date = commit_tag.date + used_tags.add(current_tag) + + yield _get_release_info( + prev_tag_name, + prev_tag_date, + changes, + changelog_release_hook, + current_tag, + ) + + prev_tag_name = current_tag.name + prev_tag_date = current_tag.date changes = defaultdict(list) - matches = pat.match(commit.message) - if not matches: + if not pat.match(commit.message): continue # Process subject from commit message @@ -153,14 +167,13 @@ def generate_tree_from_commits( change_type_map, ) - release = { - "version": current_tag_name, - "date": current_tag_date, - "changes": changes, - } - if changelog_release_hook: - release = changelog_release_hook(release, commit_tag) - yield release + yield _get_release_info( + prev_tag_name, + prev_tag_date, + changes, + changelog_release_hook, + current_tag, + ) def process_commit_message( @@ -170,7 +183,7 @@ def process_commit_message( changes: dict[str | None, list], change_type_map: dict[str, str] | None = None, ): - message: dict = { + message: dict[str, str | list[str] | Any] = { "sha1": commit.rev, "parents": commit.parents, "author": commit.author, @@ -178,13 +191,15 @@ def process_commit_message( **parsed.groupdict(), } - if processed := hook(message, commit) if hook else message: - messages = [processed] if isinstance(processed, dict) else processed - for msg in messages: - change_type = msg.pop("change_type", None) - if change_type_map: - change_type = change_type_map.get(change_type, change_type) - changes[change_type].append(msg) + if not (processed := hook(message, commit) if hook else message): + return + + processed_messages = [processed] if isinstance(processed, dict) else processed + for msg in processed_messages: + change_type = msg.pop("change_type", None) + if change_type_map: + change_type = change_type_map.get(change_type, change_type) + changes[change_type].append(msg) def order_changelog_tree(tree: Iterable, change_type_order: list[str]) -> Iterable: @@ -225,8 +240,7 @@ def render_changelog( **kwargs, ) -> str: jinja_template = get_changelog_template(loader, template) - changelog: str = jinja_template.render(tree=tree, **kwargs) - return changelog + return jinja_template.render(tree=tree, **kwargs) def incremental_build( @@ -253,7 +267,9 @@ def incremental_build( for index, line in enumerate(lines): if index == unreleased_start: skip = True - elif index == unreleased_end: + continue + + if index == unreleased_end: skip = False if ( latest_version_position is None @@ -268,13 +284,15 @@ def incremental_build( if index == latest_version_position: output_lines.extend([new_content, "\n"]) - output_lines.append(line) - if not isinstance(latest_version_position, int): - if output_lines and output_lines[-1].strip(): - # Ensure at least one blank line between existing and new content. - output_lines.append("\n") - output_lines.append(new_content) + + if isinstance(latest_version_position, int): + return output_lines + + if output_lines and output_lines[-1].strip(): + # Ensure at least one blank line between existing and new content. + output_lines.append("\n") + output_lines.append(new_content) return output_lines @@ -324,8 +342,7 @@ def get_oldest_and_newest_rev( if not (newest_tag := rules.find_tag_for(tags, newest)): raise NoCommitsFoundError("Could not find a valid revision range.") - oldest_tag = None - oldest_tag_name = None + oldest_tag_name: str | None = None if oldest: if not (oldest_tag := rules.find_tag_for(tags, oldest)): raise NoCommitsFoundError("Could not find a valid revision range.") @@ -337,17 +354,19 @@ def get_oldest_and_newest_rev( if not tags_range: raise NoCommitsFoundError("Could not find a valid revision range.") - oldest_rev: str | None = tags_range[-1].name newest_rev = newest_tag.name - # check if it's the first tag created - # and it's also being requested as part of the range - if oldest_rev == tags[-1].name and oldest_rev == oldest_tag_name: - return None, newest_rev - - # when they are the same, and it's also the - # first tag created - if oldest_rev == newest_rev: - return None, newest_rev + # Return None for oldest_rev if: + # 1. The oldest tag is the last tag in the list and matches the requested oldest tag, or + # 2. The oldest and newest tags are the same + oldest_rev: str | None = ( + None + if ( + tags_range[-1].name == tags[-1].name + and tags_range[-1].name == oldest_tag_name + or tags_range[-1].name == newest_rev + ) + else tags_range[-1].name + ) return oldest_rev, newest_rev diff --git a/commitizen/git.py b/commitizen/git.py index 19ca46b6c3..04047e01ab 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -41,6 +41,9 @@ def __eq__(self, other) -> bool: return False return self.rev == other.rev # type: ignore + def __hash__(self): + return hash(self.rev) + class GitCommit(GitObject): def __init__( diff --git a/tests/test_changelog.py b/tests/test_changelog.py index b1c7c802e1..1d6f214c10 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1529,10 +1529,46 @@ def test_get_smart_tag_range_returns_an_extra_for_a_range(tags): assert 4 == len(res) +def test_get_smart_tag_range_returns_all_tags_for_a_range(tags): + start = tags[0] + + end = tags[-1] + res = changelog.get_smart_tag_range(tags, start.name, end.name) + assert len(tags) == len(res) + + end = tags[-2] + res = changelog.get_smart_tag_range(tags, start.name, end.name) + assert len(tags) == len(res) + + end = tags[-3] + res = changelog.get_smart_tag_range(tags, start.name, end.name) + assert len(tags) - 1 == len(res) + + def test_get_smart_tag_range_returns_an_extra_for_a_single_tag(tags): start = tags[0] # len here is 1, but we expect one more tag as designed res = changelog.get_smart_tag_range(tags, start.name) - assert 2 == len(res) + assert res[0].name == tags[0].name + assert res[1].name == tags[1].name + + +def test_get_smart_tag_range_returns_an_empty_list_for_nonexistent_end_tag(tags): + start = tags[0] + res = changelog.get_smart_tag_range(tags, start.name, "nonexistent") + assert len(tags) == len(res) + + +def test_get_smart_tag_range_returns_an_empty_list_for_nonexistent_start_tag(tags): + end = tags[0] + res = changelog.get_smart_tag_range(tags, "nonexistent", end.name) + assert res[0].name == tags[1].name + + +def test_get_smart_tag_range_returns_an_empty_list_for_nonexistent_start_and_end_tags( + tags, +): + res = changelog.get_smart_tag_range(tags, "nonexistent", "nonexistent") + assert 0 == len(res) @dataclass