@@ -1693,3 +1693,106 @@ def test_changelog_template_incremental_variable(
16931693 util .run_cli ("changelog" , "--file-name" , target , "--incremental" )
16941694 out = Path (target ).read_text (encoding = "utf-8" )
16951695 file_regression .check (out , extension = ".incremental.md" )
1696+
1697+
1698+ @pytest .mark .usefixtures ("tmp_commitizen_project" )
1699+ @pytest .mark .freeze_time ("2026-09-02" )
1700+ def test_changelog_previous_release_skips_unreachable_tags (
1701+ config_path : Path ,
1702+ changelog_path : Path ,
1703+ monkeypatch : pytest .MonkeyPatch ,
1704+ util : UtilFixture ,
1705+ ):
1706+ """Regression test for #2083.
1707+
1708+ ``cz changelog`` resolves the previous-release tag by walking the
1709+ creatordate-sorted tag list. A parallel maintenance branch's tag must
1710+ be skipped if it is not an ancestor of HEAD — otherwise the rendered
1711+ range pulls in commits from a branch that is not part of the current
1712+ release line.
1713+ """
1714+ # Arrange — bare tag format, then a parallel v4 line and a main line.
1715+ with config_path .open ("a" , encoding = "utf-8" ) as f :
1716+ f .write ('tag_format = "$version"\n ' )
1717+
1718+ # The fixture resets GIT_COMMITTER_DATE on every ``tick``/
1719+ # ``patch_env``, so ``util.create_tag`` would clobber the date set
1720+ # here. Drive ``git tag -a`` directly via ``cmd.run`` to keep the
1721+ # creatordate stable, then restore the fixture-managed env so
1722+ # subsequent ``util.tick`` calls keep advancing frozen time as
1723+ # expected.
1724+ from commitizen import cmd as _cmd
1725+
1726+ def _tag_at (tag , iso_date ):
1727+ """Create an annotated tag pinned to ``iso_date`` regardless of the
1728+ fixture's frozen time, then restore the fixture env.
1729+ """
1730+ monkeypatch .setenv ("GIT_COMMITTER_DATE" , iso_date )
1731+ result = _cmd .run (["git" , "tag" , "-a" , tag , "-m" , tag ])
1732+ assert result .return_code == 0 , result .err
1733+ util .patch_env ()
1734+
1735+ # Main line: 5.3.1, then branch off to v4, then 5.4.0.
1736+ util .create_file_and_commit ("feat: main feature a" )
1737+ _tag_at ("5.3.1" , "2026-06-29T00:00:00" )
1738+
1739+ util .create_branch ("v4" )
1740+ util .switch_branch ("v4" )
1741+ util .create_file_and_commit ("feat: v4 feature a" )
1742+
1743+ util .switch_branch ("master" )
1744+ util .create_file_and_commit ("feat: main feature b" )
1745+ _tag_at ("5.4.0" , "2026-09-02T00:00:00" )
1746+
1747+ # v4 line ships 4.12.0 between 5.3.1 and 5.4.0 in time. 4.12.0's tag
1748+ # is NOT an ancestor of master — it sits on the v4 branch HEAD which
1749+ # is never merged back.
1750+ util .switch_branch ("v4" )
1751+ util .create_file_and_commit ("feat: v4 feature b" )
1752+ _tag_at ("4.12.0" , "2026-07-14T00:00:00" )
1753+ util .switch_branch ("master" )
1754+
1755+ # Spy on git.get_tags so the test fails when the production call site
1756+ # drops ``reachable_only=True``. Without the spy, reverting the
1757+ # production fix would still pass because the test only checks
1758+ # render output.
1759+ from commitizen import git as cz_git
1760+
1761+ get_tags_calls : list [dict ] = []
1762+
1763+ real_get_tags = cz_git .get_tags
1764+
1765+ def _tracked_get_tags (* args , ** kwargs ):
1766+ """Wrap the real ``git.get_tags`` to record every call.
1767+
1768+ The wrap must call through the imported ``real_get_tags`` rather
1769+ than the module attribute, which the test itself patches and so
1770+ would recurse forever.
1771+ """
1772+ get_tags_calls .append ({"args" : args , "kwargs" : kwargs })
1773+ return real_get_tags (* args , ** kwargs )
1774+
1775+ monkeypatch .setattr ("commitizen.commands.changelog.git.get_tags" , _tracked_get_tags )
1776+
1777+ # Act — single-version form goes through ``get_oldest_and_newest_rev``
1778+ # because ``rev_range`` carries the version string.
1779+ util .run_cli ("changelog" , "5.4.0" , "--file-name" , str (changelog_path ))
1780+ out = changelog_path .read_text (encoding = "utf-8" )
1781+
1782+ # Assert — the production call site must ask git for reachable tags
1783+ # only. If a future change drops ``reachable_only=True`` from the
1784+ # call site the spy flags it.
1785+ assert get_tags_calls , "production call site never invoked git.get_tags"
1786+ assert all (
1787+ call ["kwargs" ].get ("reachable_only" ) is True for call in get_tags_calls
1788+ ), f"git.get_tags called without reachable_only=True: { get_tags_calls !r} "
1789+
1790+ # And the rendered output must contain the 5.4.0 entry whose body
1791+ # only carries main-line commits; the v4-line commits must not leak
1792+ # in via the wrong previous-release tag (which would otherwise
1793+ # include the 4.12.0 commit on the v4 branch once the date-sorted
1794+ # tag list resolves to 4.12.0 as the previous tag).
1795+ assert "## 5.4.0" in out
1796+ assert "main feature b" in out
1797+ assert "v4 feature a" not in out
1798+ assert "v4 feature b" not in out
0 commit comments