Skip to content

Commit 50d8156

Browse files
feat: add changelog_skip_prereleases setting to omit prerelease entries
When changelog_skip_prereleases = true is set, prerelease tags (rc, alpha, beta, dev) are omitted entirely from the generated changelog. Both cz changelog and cz bump --changelog honour the setting. When both changelog_skip_prereleases and changelog_merge_prerelease are set, skip takes precedence and prerelease entries are dropped. Closes #1218 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5c85feb commit 50d8156

7 files changed

Lines changed: 226 additions & 29 deletions

File tree

commitizen/commands/changelog.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,32 @@ def __init__(self, config: BaseConfig, arguments: ChangelogArgs) -> None:
5555

5656
self.config = config
5757

58-
changelog_file_name = arguments.get("file_name") or self.config.settings.get(
59-
"changelog_file"
60-
)
58+
# Distinguish the source of the file name so we can apply the correct
59+
# path resolution strategy:
60+
# • CLI-provided (--file-name): use as-is, relative to the current
61+
# working directory — standard CLI convention.
62+
# • Config-provided (changelog_file setting): resolve relative to the
63+
# config file's directory so the path is stable regardless of where
64+
# the user runs `cz` from.
65+
file_name_from_args: str | None = arguments.get("file_name") or None
66+
file_name_from_config: str | None = self.config.settings.get("changelog_file")
67+
changelog_file_name = file_name_from_args or file_name_from_config
6168
if not isinstance(changelog_file_name, str):
6269
raise NotAllowed(
6370
"Changelog file name is broken.\n"
6471
"Check the flag `--file-name` in the terminal "
6572
f"or the setting `changelog_file` in {self.config.path}"
6673
)
67-
self.file_name = (
68-
Path(self.config.path.parent, changelog_file_name).as_posix()
69-
if self.config.path is not None
70-
else changelog_file_name
71-
)
74+
if file_name_from_args:
75+
# Explicit CLI argument — keep relative to cwd (or use as-is if absolute).
76+
self.file_name = file_name_from_args
77+
elif self.config.path is not None:
78+
# From configuration — anchor to the config file's directory.
79+
self.file_name = Path(
80+
self.config.path.parent, changelog_file_name
81+
).as_posix()
82+
else:
83+
self.file_name = changelog_file_name
7284

7385
self.cz = factory.committer_factory(self.config)
7486

@@ -114,6 +126,9 @@ def __init__(self, config: BaseConfig, arguments: ChangelogArgs) -> None:
114126
ignored_tag_formats=self.config.settings["ignored_tag_formats"],
115127
merge_prereleases=arguments.get("merge_prerelease")
116128
or self.config.settings["changelog_merge_prerelease"],
129+
skip_prereleases=bool(
130+
self.config.settings.get("changelog_skip_prereleases")
131+
),
117132
)
118133

119134
self.template = (

commitizen/defaults.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class Settings(TypedDict, total=False):
4040
changelog_format: str | None
4141
changelog_incremental: bool
4242
changelog_merge_prerelease: bool
43+
changelog_skip_prereleases: bool
4344
changelog_start_rev: str | None
4445
customize: CzSettings
4546
encoding: str
@@ -103,6 +104,7 @@ class Settings(TypedDict, total=False):
103104
"changelog_incremental": False,
104105
"changelog_start_rev": None,
105106
"changelog_merge_prerelease": False,
107+
"changelog_skip_prereleases": False,
106108
"update_changelog_on_bump": False,
107109
"use_shortcuts": False,
108110
"major_version_zero": False,

commitizen/tags.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class TagRules:
8686
legacy_tag_formats: Sequence[str] = field(default_factory=list)
8787
ignored_tag_formats: Sequence[str] = field(default_factory=list)
8888
merge_prereleases: bool = False
89+
skip_prereleases: bool = False
8990

9091
@property
9192
def tag_formats(self) -> Iterable[str]:
@@ -166,7 +167,13 @@ def include_in_changelog(self, tag: GitTag) -> bool:
166167
version = self.extract_version(tag)
167168
except InvalidVersion:
168169
return False
169-
return not (self.merge_prereleases and version.is_prerelease)
170+
if version.is_prerelease:
171+
# skip_prereleases wins over merge_prereleases when both are set
172+
if self.skip_prereleases:
173+
return False
174+
if self.merge_prereleases:
175+
return False
176+
return True
170177

171178
def search_version(self, text: str, last: bool = False) -> VersionTag | None:
172179
"""
@@ -265,6 +272,7 @@ def from_settings(cls, settings: Settings) -> Self:
265272
legacy_tag_formats=settings["legacy_tag_formats"],
266273
ignored_tag_formats=settings["ignored_tag_formats"],
267274
merge_prereleases=settings["changelog_merge_prerelease"],
275+
skip_prereleases=settings["changelog_skip_prereleases"],
268276
)
269277

270278
def _extract_version(self, match: re.Match[str]) -> str:

docs/commands/changelog.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ Specify the name of the output file. Note that changelog generation only works w
8585
cz changelog --file-name="CHANGES.md"
8686
```
8787

88+
When provided on the command line, the path is interpreted **relative to the current working directory** (standard CLI convention).
89+
8890
This value can be updated in the configuration file with the key `changelog_file` under `tool.commitizen`.
8991

9092
```toml
@@ -93,6 +95,8 @@ This value can be updated in the configuration file with the key `changelog_file
9395
changelog_file = "CHANGES.md"
9496
```
9597

98+
When set in the configuration file, the path is resolved **relative to the configuration file's directory** (usually the project root). This means the location of the changelog is stable regardless of which directory you run `cz` from.
99+
96100
### `--incremental`
97101

98102
Build from the latest version found in changelog.
@@ -147,6 +151,30 @@ This flag can be set in the configuration file with the key `changelog_merge_pre
147151
changelog_merge_prerelease = true
148152
```
149153

154+
### `changelog_skip_prereleases`
155+
156+
Omits all prerelease versions (e.g. `rc`, `alpha`, `beta`, `dev`) from the changelog entirely. Stable-release entries are kept, but their commits are attributed only to the stable release — prerelease headers never appear.
157+
158+
This is a configuration-only option (no CLI flag); enable it in your `pyproject.toml`:
159+
160+
```toml
161+
[tool.commitizen]
162+
changelog_skip_prereleases = true
163+
```
164+
165+
With this setting, a history such as:
166+
167+
```
168+
0.1.0-a0 → 0.1.0-b0 → 0.1.0
169+
```
170+
171+
will produce a changelog that shows only `0.1.0`, without `0.1.0-a0` or `0.1.0-b0` entries.
172+
173+
!!! note
174+
When both `changelog_skip_prereleases = true` and `changelog_merge_prerelease = true` are set,
175+
`changelog_skip_prereleases` takes precedence and prerelease entries are dropped rather than
176+
merged.
177+
150178
### `--template`
151179

152180
Provide your own changelog Jinja template by using the `template` settings or the `--template` parameter.

tests/commands/test_changelog_command.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,79 @@ def test_changelog_config_flag_merge_prerelease(
599599
file_regression.check(out, extension=".md")
600600

601601

602+
@pytest.mark.usefixtures("tmp_commitizen_project")
603+
def test_changelog_config_flag_skip_prereleases(
604+
capsys: pytest.CaptureFixture,
605+
config_path: Path,
606+
util: UtilFixture,
607+
):
608+
"""changelog_skip_prereleases = true must omit prerelease entries from changelog."""
609+
with config_path.open("a", encoding="utf-8") as f:
610+
f.write("changelog_skip_prereleases = true\n")
611+
612+
util.create_file_and_commit("feat: initial feature")
613+
util.run_cli("bump", "--yes")
614+
capsys.readouterr()
615+
616+
util.create_file_and_commit("feat: add new output")
617+
util.create_file_and_commit("fix: output glitch")
618+
619+
# bump to a prerelease
620+
util.run_cli("bump", "--prerelease", "rc", "--yes")
621+
capsys.readouterr()
622+
623+
util.create_file_and_commit("fix: another fix")
624+
util.create_file_and_commit("feat: more stuff")
625+
626+
# bump to stable
627+
util.run_cli("bump", "--yes")
628+
capsys.readouterr()
629+
630+
with pytest.raises(DryRunExit):
631+
util.run_cli("changelog", "--dry-run")
632+
633+
out, _ = capsys.readouterr()
634+
# The prerelease version (e.g. 0.2.0rc1) must not appear
635+
assert "rc" not in out
636+
# Commit messages from stable releases must be present
637+
assert "add new output" in out
638+
assert "more stuff" in out
639+
640+
641+
@pytest.mark.usefixtures("tmp_commitizen_project")
642+
def test_changelog_config_skip_prereleases_wins_over_merge_prerelease(
643+
capsys: pytest.CaptureFixture,
644+
config_path: Path,
645+
util: UtilFixture,
646+
):
647+
"""When both skip_prereleases and merge_prerelease are set, skip wins."""
648+
with config_path.open("a", encoding="utf-8") as f:
649+
f.write("changelog_skip_prereleases = true\n")
650+
f.write("changelog_merge_prerelease = true\n")
651+
652+
util.create_file_and_commit("feat: initial feature")
653+
util.run_cli("bump", "--yes")
654+
capsys.readouterr()
655+
656+
util.create_file_and_commit("feat: add new output")
657+
util.run_cli("bump", "--prerelease", "alpha", "--yes")
658+
capsys.readouterr()
659+
660+
util.create_file_and_commit("fix: another fix")
661+
util.run_cli("bump", "--yes")
662+
capsys.readouterr()
663+
664+
with pytest.raises(DryRunExit):
665+
util.run_cli("changelog", "--dry-run")
666+
667+
out, _ = capsys.readouterr()
668+
# Prerelease entries must not appear
669+
assert "alpha" not in out
670+
# Stable commit messages must be present
671+
assert "add new output" in out
672+
assert "another fix" in out
673+
674+
602675
@pytest.mark.usefixtures("tmp_commitizen_project")
603676
def test_changelog_config_start_rev_option(
604677
capsys: pytest.CaptureFixture,

tests/test_changelog.py

Lines changed: 89 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,6 +1191,41 @@ def test_generate_tree_from_commits_with_no_commits(tags):
11911191
assert tuple(tree) == ({"changes": {}, "date": "", "version": "Unreleased"},)
11921192

11931193

1194+
def test_generate_tree_from_commits_skip_prereleases(gitcommits, tags):
1195+
"""skip_prereleases=True must omit all prerelease entries from the tree."""
1196+
parser = ConventionalCommitsCz.commit_parser
1197+
changelog_pattern = ConventionalCommitsCz.bump_pattern
1198+
rules = changelog.TagRules(skip_prereleases=True)
1199+
tree = list(
1200+
changelog.generate_tree_from_commits(
1201+
gitcommits, tags, parser, changelog_pattern, rules=rules
1202+
)
1203+
)
1204+
versions = [entry["version"] for entry in tree]
1205+
# Prerelease tags from TAGS: "1.0.0b2", "v1.0.0b1"
1206+
assert "1.0.0b2" not in versions
1207+
assert "v1.0.0b1" not in versions
1208+
# Stable releases must still be present
1209+
assert "v1.2.0" in versions
1210+
assert "v1.0.0" in versions
1211+
1212+
1213+
def test_skip_prereleases_wins_over_merge_prereleases(gitcommits, tags):
1214+
"""When both skip_prereleases and merge_prereleases are set, skip wins."""
1215+
parser = ConventionalCommitsCz.commit_parser
1216+
changelog_pattern = ConventionalCommitsCz.bump_pattern
1217+
rules = changelog.TagRules(skip_prereleases=True, merge_prereleases=True)
1218+
tree = list(
1219+
changelog.generate_tree_from_commits(
1220+
gitcommits, tags, parser, changelog_pattern, rules=rules
1221+
)
1222+
)
1223+
versions = [entry["version"] for entry in tree]
1224+
# Prereleases must be absent even though merge_prereleases=True
1225+
assert "1.0.0b2" not in versions
1226+
assert "v1.0.0b1" not in versions
1227+
1228+
11941229
@pytest.mark.parametrize(
11951230
("change_type_order", "expected_reordering"),
11961231
[
@@ -1651,35 +1686,69 @@ def test_tags_rules_get_version_tags(capsys: pytest.CaptureFixture):
16511686

16521687

16531688
@pytest.mark.usefixtures("in_repo_root")
1654-
def test_changelog_file_name_from_args_and_config():
1689+
@pytest.mark.parametrize(
1690+
("args_file_name", "config_file_name", "config_path", "expected"),
1691+
[
1692+
# CLI-provided --file-name: must be used as-is (relative to cwd).
1693+
# The config-dir anchor must NOT be applied.
1694+
(
1695+
"CUSTOM.md",
1696+
"CHANGELOG.md",
1697+
Path("/my/project/pyproject.toml"),
1698+
"CUSTOM.md",
1699+
),
1700+
# Config-provided changelog_file: must be resolved relative to the
1701+
# config file's parent directory, not the cwd.
1702+
(
1703+
None,
1704+
"CHANGELOG.md",
1705+
Path("/my/project/pyproject.toml"),
1706+
Path("/my/project/CHANGELOG.md").as_posix(),
1707+
),
1708+
# Nested config-provided path: still resolved relative to config dir.
1709+
(
1710+
None,
1711+
"docs/CHANGELOG.md",
1712+
Path("/my/project/pyproject.toml"),
1713+
Path("/my/project/docs/CHANGELOG.md").as_posix(),
1714+
),
1715+
# CLI-provided nested path: used as-is (relative to cwd).
1716+
(
1717+
"changelog/CHANGES.rst",
1718+
"CHANGELOG.md",
1719+
Path("/my/project/pyproject.toml"),
1720+
"changelog/CHANGES.rst",
1721+
),
1722+
],
1723+
)
1724+
def test_changelog_file_name_resolution(
1725+
args_file_name: str | None,
1726+
config_file_name: str,
1727+
config_path: Path,
1728+
expected: str,
1729+
):
1730+
"""CLI --file-name is relative to cwd; config changelog_file is relative to
1731+
the config file's directory. Fixes https://github.com/commitizen-tools/commitizen/issues/1411
1732+
"""
16551733
mock_config = Mock(spec=BaseConfig)
16561734
mock_path = Mock(spec=Path)
1657-
mock_path.parent = Path("/my/project")
1735+
mock_path.parent = config_path.parent
16581736
mock_config.path = mock_path
16591737
mock_config.settings = {
16601738
"name": "cz_conventional_commits",
1661-
"changelog_file": "CHANGELOG.md",
1739+
"changelog_file": config_file_name,
16621740
"encoding": "utf-8",
1663-
"changelog_start_rev": "v1.0.0",
1741+
"changelog_start_rev": None,
16641742
"tag_format": "$version",
16651743
"legacy_tag_formats": [],
16661744
"ignored_tag_formats": [],
1667-
"incremental": True,
1668-
"changelog_merge_prerelease": True,
1745+
"changelog_incremental": False,
1746+
"changelog_merge_prerelease": False,
16691747
}
16701748

1671-
args = {
1672-
"file_name": "CUSTOM.md",
1673-
"unreleased_version": "1.0.1",
1674-
}
1675-
changelog = Changelog(mock_config, args)
1676-
assert (
1677-
Path(changelog.file_name).resolve() == Path("/my/project/CUSTOM.md").resolve()
1678-
)
1749+
args: dict = {"unreleased_version": "1.0.1"}
1750+
if args_file_name is not None:
1751+
args["file_name"] = args_file_name
16791752

1680-
args = {"unreleased_version": "1.0.1"}
1681-
changelog = Changelog(mock_config, args)
1682-
assert (
1683-
Path(changelog.file_name).resolve()
1684-
== Path("/my/project/CHANGELOG.md").resolve()
1685-
)
1753+
cl = Changelog(mock_config, args) # type: ignore[arg-type]
1754+
assert cl.file_name == expected

tests/test_conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"changelog_incremental": False,
101101
"changelog_start_rev": None,
102102
"changelog_merge_prerelease": False,
103+
"changelog_skip_prereleases": False,
103104
"update_changelog_on_bump": False,
104105
"use_shortcuts": False,
105106
"major_version_zero": False,
@@ -140,6 +141,7 @@
140141
"changelog_incremental": False,
141142
"changelog_start_rev": None,
142143
"changelog_merge_prerelease": False,
144+
"changelog_skip_prereleases": False,
143145
"update_changelog_on_bump": False,
144146
"use_shortcuts": False,
145147
"major_version_zero": False,

0 commit comments

Comments
 (0)