From 7898cd0f0d3f43c8aee043ddc3bd867651a1c71f Mon Sep 17 00:00:00 2001 From: Tim Hsiung Date: Sat, 9 May 2026 19:43:12 +0800 Subject: [PATCH 1/3] fix(changelog): add `changelog_subject_only` to skip body parsing Closes #1267. `generate_tree_from_commits()` historically parses the commit subject and each `\n\n`-separated body block against `commit_parser`, so a commit whose subject is `feat: ...` and whose body contains another `refactor: ...` line produces two changelog entries instead of one. Maintainer ack on #1267 confirms this is undesirable, but changing the default is a behavioural break. This change introduces `changelog_subject_only` (default `false`) on `Settings`. When set to `true`, the body iteration in `generate_tree_from_commits()` is skipped, leaving only the subject line to be matched. The setting is plumbed through `commands/changelog.py` so both `cz changelog` and `cz bump --changelog` honour it. A regression test exercises both modes against a commit whose body contains a parser-matching block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- commitizen/changelog.py | 15 +++++++++----- commitizen/commands/changelog.py | 1 + commitizen/defaults.py | 2 ++ docs/commands/changelog.md | 20 ++++++++++++++++++ tests/test_changelog.py | 35 ++++++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/commitizen/changelog.py b/commitizen/changelog.py index d8b8accca..514659437 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -100,6 +100,7 @@ def generate_tree_from_commits( changelog_release_hook: ChangelogReleaseHook | None = None, rules: TagRules | None = None, during_version_bump: bool = False, + subject_only: bool = False, ) -> Generator[dict[str, Any], None, None]: pat = re.compile(changelog_pattern) map_pat = re.compile(commit_parser, re.MULTILINE) @@ -147,11 +148,15 @@ def generate_tree_from_commits( if not pat.match(commit.message): continue - # Process subject and body from commit message - for message in chain( - [map_pat.match(commit.message)], - (body_map_pat.match(block) for block in commit.body.split("\n\n")), - ): + # Process subject; optionally also parse body blocks for nested entries + # (legacy default behaviour, controlled by `changelog_subject_only`). + subject_match = map_pat.match(commit.message) + body_matches: Iterable[re.Match[str] | None] = ( + () + if subject_only + else (body_map_pat.match(block) for block in commit.body.split("\n\n")) + ) + for message in chain([subject_match], body_matches): if message: process_commit_message( changelog_message_builder_hook, diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 5521da373..5919e4f34 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -276,6 +276,7 @@ def __call__(self) -> None: changelog_release_hook=self.cz.changelog_release_hook, rules=self.tag_rules, during_version_bump=self.during_version_bump, + subject_only=self.config.settings["changelog_subject_only"], ) if self.change_type_order: tree = changelog.generate_ordered_changelog_tree( diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 4865ccc18..1ae67beda 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -41,6 +41,7 @@ class Settings(TypedDict, total=False): changelog_incremental: bool changelog_merge_prerelease: bool changelog_start_rev: str | None + changelog_subject_only: bool customize: CzSettings encoding: str extras: dict[str, Any] @@ -103,6 +104,7 @@ class Settings(TypedDict, total=False): "changelog_incremental": False, "changelog_start_rev": None, "changelog_merge_prerelease": False, + "changelog_subject_only": False, "update_changelog_on_bump": False, "use_shortcuts": False, "major_version_zero": False, diff --git a/docs/commands/changelog.md b/docs/commands/changelog.md index 8b7a7a4d4..24fc4e878 100644 --- a/docs/commands/changelog.md +++ b/docs/commands/changelog.md @@ -147,6 +147,26 @@ This flag can be set in the configuration file with the key `changelog_merge_pre changelog_merge_prerelease = true ``` +### `changelog_subject_only` + +By default, Commitizen parses both the subject line and any `\n\n`-separated body blocks of each commit against `commit_parser`, so a commit such as + +``` +feat: new feature + +refactor: incidental cleanup +``` + +produces *two* changelog entries (one under `feat`, one under `refactor`). Set this configuration to `true` to limit changelog parsing to the subject line only. + +```toml +[tool.commitizen] +# ... +changelog_subject_only = true +``` + +The default (`false`) preserves the historical behaviour. + ### `--template` Provide your own changelog Jinja template by using the `template` settings or the `--template` parameter. diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 11c3a6044..7fc24d5ff 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1191,6 +1191,41 @@ def test_generate_tree_from_commits_with_no_commits(tags): assert tuple(tree) == ({"changes": {}, "date": "", "version": "Unreleased"},) +def test_generate_tree_from_commits_subject_only_skips_body_blocks(tags): + """`subject_only=True` ignores parser-matching blocks inside `commit.body`. + + Regression for #1267 where, e.g., a commit subject `feat: new feature` + with a body containing `refactor: cleanup` produced two changelog + entries instead of one. + """ + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.bump_pattern + + commit = git.GitCommit( + rev="abc123", + title="feat: new feature", + body="some prose\n\nrefactor: incidental cleanup", + author="Commitizen", + author_email="author@cz.dev", + ) + + default_tree = list( + changelog.generate_tree_from_commits([commit], tags, parser, changelog_pattern) + ) + assert default_tree[0]["changes"].keys() == {"feat", "refactor"} + + subject_only_tree = list( + changelog.generate_tree_from_commits( + [commit], + tags, + parser, + changelog_pattern, + subject_only=True, + ) + ) + assert subject_only_tree[0]["changes"].keys() == {"feat"} + + @pytest.mark.parametrize( ("change_type_order", "expected_reordering"), [ From 591fc33d1e4259ff6b994b20fc1b6196ecfd0d4f Mon Sep 17 00:00:00 2001 From: Tim Hsiung Date: Sat, 9 May 2026 19:49:16 +0800 Subject: [PATCH 2/3] test(conf): add `changelog_subject_only` to expected settings Followup to fix(changelog): the new default key broke `tests/test_conf.py::TestReadCfg` because the expected `Settings` literals pinned the full dict and didn''t know about the new field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_conf.py b/tests/test_conf.py index c004e96e1..c4983c374 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -100,6 +100,7 @@ "changelog_incremental": False, "changelog_start_rev": None, "changelog_merge_prerelease": False, + "changelog_subject_only": False, "update_changelog_on_bump": False, "use_shortcuts": False, "major_version_zero": False, @@ -140,6 +141,7 @@ "changelog_incremental": False, "changelog_start_rev": None, "changelog_merge_prerelease": False, + "changelog_subject_only": False, "update_changelog_on_bump": False, "use_shortcuts": False, "major_version_zero": False, From 292feb4f37c9716d8c5c81310da8f9dc88afe101 Mon Sep 17 00:00:00 2001 From: Tim Hsiung Date: Sat, 9 May 2026 20:33:43 +0800 Subject: [PATCH 3/3] test(changelog): cover changelog_subject_only config wiring Add an end-to-end test that sets `changelog_subject_only = true` in the project configuration, creates a commit with a parser-matching block in its body, and asserts that `cz changelog --dry-run` only emits the subject entry. Catches typos in the setting key at `commands/changelog.py:279` (which would otherwise silently fall back to `False`); manually verified by injecting a typo, observing the test fail with KeyError, then restoring. Addresses the only review finding from PR #1974. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/commands/test_changelog_command.py | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index b2d024ac7..9dc4adf05 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -87,6 +87,32 @@ def test_changelog_with_different_cz( file_regression.check(out, extension=".md") +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_subject_only_setting_skips_body_parsing( + util: UtilFixture, + config_path: Path, + capsys: pytest.CaptureFixture, +): + """End-to-end coverage for `changelog_subject_only` (#1267 / #1974). + + Verifies the config-key wiring in `commands/changelog.py`: when the + setting is `true`, parser-matching blocks in commit bodies are + ignored. Catches regressions where the setting key is mistyped or + not propagated to `generate_tree_from_commits`. + """ + with config_path.open("a") as f: + f.write("changelog_subject_only = true\n") + + util.create_file_and_commit("feat: add new output\n\nrefactor: incidental cleanup") + + with pytest.raises(DryRunExit): + util.run_cli("changelog", "--dry-run") + out, _ = capsys.readouterr() + + assert "add new output" in out + assert "incidental cleanup" not in out + + @pytest.mark.usefixtures("tmp_commitizen_project") def test_changelog_from_start( changelog_format: ChangelogFormat,