Skip to content

fix(bump): use VersionIncrement ordering for bump detection - #2097

Open
bearomorphism wants to merge 5 commits into
masterfrom
fm/commitizen-bumprule-pr
Open

bearomorphism wants to merge 5 commits into
masterfrom
fm/commitizen-bumprule-pr

Conversation

@bearomorphism

Copy link
Copy Markdown
Collaborator

Intent

I remember I have a PR in commitizen about BumpRule which is aiming to redesign bump_pattern thing. Break it down to a smaller first PR. I'd like to get rid of find_increment first. Delete it if we can use max on the list of computed version increments. I want to make VersionIncrement comparable more precisely. The computed increment values should preserve existing bump_pattern and bump_map custom-plugin behavior, commit filtering, major-version-zero handling, and current no-increment behavior without redesigning the rest of BumpRule.

What Changed

  • Replaced the old find_increment helper with comparable VersionIncrement utilities that derive the highest bump directly from commit messages and bump-map matches.
  • Updated the bump and version commands to use the shared increment logic while preserving filtered-commit handling, major-version-zero rules, custom bump_map ordering, and the existing no-increment behavior.
  • Moved increment-selection coverage into tests/test_version_increment.py, added command tests for invalid custom bump_map values, and documented that bump_map can use None/null to match without bumping.

Risk Assessment

✅ Low: The change is narrowly scoped to replacing increment selection with comparable enums, and the review did not find any remaining source-visible regressions against the required bump-pattern, bump-map, filtering, major-version-zero, or no-increment behaviors.

Testing

I drove seven live scenarios against isolated git repositories and the real Commitizen runtime, capturing CLI/helper transcripts in the evidence directory; all exercised behaviors matched the author intent, with no live failures and no untested runtime gaps for this change.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Run cz bump --allow-no-commit after a docs-only commit and get a PATCH bump instead of a no-increment failure ✅ pass live live-scenario-no-increment-patch.log
Run cz bump --yes --allow-no-commit with a custom matched MINORR bump_map value and get an invalid-increment error with no fallback tag ✅ pass live live-scenario-invalid-minorr.log
Run cz bump --yes --allow-no-commit with a custom matched string NONE bump_map value and get the legacy invalid-increment error with no fallback tag ✅ pass live live-scenario-string-none-invalid.log
Run cz bump --yes on a multiline custom commit whose first matching line is MAJOR and a later line is invalid, and still get a MAJOR bump ✅ pass live live-scenario-major-short-circuit.log
Run cz version --project --next USE_GIT_COMMITS with major_version_zero = true and a breaking commit, and see 0.2.0 ✅ pass live live-scenario-version-major-version-zero.log
Run cz version --project --next USE_GIT_COMMITS with a plugin filter that keeps only AppA commits, and see the filtered PATCH version 1.0.1 ✅ pass live live-scenario-version-filter-hook.log
Scan multiple messages through VersionIncrement.get_highest_by_messages and observe result MAJOR with one bump-pattern compile ✅ pass live live-scenario-compile-once.log
Evidence: docs-only allow-no-commit falls back to PATCH
$ python -m commitizen bump --yes
bump: version 0.1.0 → 0.2.0
tag to create: 0.2.0
increment detected: MINOR

[master bbefef4] bump: version 0.1.0 → 0.2.0
 1 file changed, 1 insertion(+), 1 deletion(-)

Done!
$ git commit -m "docs: update guide"
$ python -m commitizen bump --allow-no-commit
bump: version 0.2.0 → 0.2.1
tag to create: 0.2.1
increment detected: PATCH

[master 111d96c] bump: version 0.2.0 → 0.2.1
 1 file changed, 1 insertion(+), 1 deletion(-)

Done!
$ git tag --list
0.2.0
0.2.1
Evidence: invalid custom MINORR bump_map value is rejected
$ python -m commitizen bump --yes --allow-no-commit
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/__main__.py", line 4, in <module>
    main()
    ~~~~^^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/cli.py", line 727, in main
    args.func(conf, arguments)()  # type: ignore[arg-type]
    ~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/commands/bump.py", line 283, in __call__
    increment, new_version = self._resolve_increment_and_new_version(
                             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
        current_version, current_tag
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/commands/bump.py", line 221, in _resolve_increment_and_new_version
    increment = self._find_increment(commits)
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/commands/bump.py", line 163, in _find_increment
    increment = VersionIncrement.get_highest_by_messages(
        (commit.message for commit in self.cz.filter_commits_before_bump(commits)),
        bump_pattern,
        bump_map,
    )
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/version_increment.py", line 124, in get_highest_by_messages
    return max(
        (
    ...<3 lines>...
        default=cls.NONE,
    )
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/version_increment.py", line 126, in <genexpr>
    cls.from_message(message, select_pattern, increments_map)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/version_increment.py", line 103, in from_message
    cls.from_match(result.group(1), increments_map),
    ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/version_increment.py", line 80, in from_match
    return cls._from_bump_map_value(increment)
           ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/version_increment.py", line 67, in _from_bump_map_value
    raise ValueError(f"Invalid bump increment: {value!r}")
ValueError: Invalid bump increment: 'MINORR'
[exit-status] 1
$ git tag --list
0.1.1
Evidence: string NONE bump_map value is rejected
$ python -m commitizen bump --yes --allow-no-commit
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/__main__.py", line 4, in <module>
    main()
    ~~~~^^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/cli.py", line 727, in main
    args.func(conf, arguments)()  # type: ignore[arg-type]
    ~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/commands/bump.py", line 283, in __call__
    increment, new_version = self._resolve_increment_and_new_version(
                             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
        current_version, current_tag
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/commands/bump.py", line 221, in _resolve_increment_and_new_version
    increment = self._find_increment(commits)
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/commands/bump.py", line 163, in _find_increment
    increment = VersionIncrement.get_highest_by_messages(
        (commit.message for commit in self.cz.filter_commits_before_bump(commits)),
        bump_pattern,
        bump_map,
    )
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/version_increment.py", line 124, in get_highest_by_messages
    return max(
        (
    ...<3 lines>...
        default=cls.NONE,
    )
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/version_increment.py", line 126, in <genexpr>
    cls.from_message(message, select_pattern, increments_map)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/version_increment.py", line 103, in from_message
    cls.from_match(result.group(1), increments_map),
    ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/version_increment.py", line 80, in from_match
    return cls._from_bump_map_value(increment)
           ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
  File "~/.no-mistakes/worktrees/9337bcc3ac62/01M3SM2AZ1C6FBK981H0S7PQGK/commitizen/version_increment.py", line 67, in _from_bump_map_value
    raise ValueError(f"Invalid bump increment: {value!r}")
ValueError: Invalid bump increment: 'NONE'
[exit-status] 1
$ git tag --list
0.1.1
Evidence: multiline MAJOR commit short-circuits later invalid line
$ python -m commitizen bump --yes
bump: version 0.1.0 → 1.0.0
tag to create: 1.0.0
increment detected: MAJOR

[master d6bc4a3] bump: version 0.1.0 → 1.0.0
 1 file changed, 1 insertion(+), 1 deletion(-)

Done!
$ git tag --list
1.0.0
Evidence: USE_GIT_COMMITS respects major_version_zero
$ python -m commitizen version --project --next USE_GIT_COMMITS
0.2.0
Evidence: USE_GIT_COMMITS respects filter_commits_before_bump hook
$ python - <<PY  # patch registry with filtered plugin and invoke commitizen.cli.main()
1.0.1
Evidence: multi-message scan returns MAJOR with one bump-pattern compile
$ python - <<PY  # instrument commitizen.version_increment.re.compile for the active bump pattern
{"increment": "MAJOR", "compile_calls": 1}
- Outcome: 🔧 1 issue found → no changes applied ✅ across 2 runs (14m58s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed (3) ✅
  • 🚨 commitizen/version_increment.py:33 - This refactor no longer preserves the existing custom bump_map behavior the intent requires ("The computed increment values should preserve existing bump_pattern and bump_map custom-plugin behavior"). from_value() now coerces any unrecognized bump-map value to NONE, so a custom rule such as bump_map = {&#39;^new&#39;: &#39;MINORR&#39;} plus a matching commit now silently produces NONE instead of failing as the old find_increment() did at VERSION_TYPES.index(&#39;MINORR&#39;). In cz bump, that can degrade into NoneIncrementExit or even a PATCH bump under --allow-no-commit, which is a wrong result without an error.
  • ⚠️ commitizen/version_increment.py:66 - from_message() recompiles the same bump_pattern for every commit message. The deleted find_increment() compiled the regex once per scan, so cz bump and cz version --next USE_GIT_COMMITS now do one extra regex compilation per commit since the last tag. On large histories this is an avoidable performance regression; hoist the compiled pattern to the outer helper and reuse it across messages.

🔧 Fix applied.
1 error still open:

  • 🚨 commitizen/version_increment.py:95 - from_message() no longer preserves the old short-circuit-on-MAJOR behavior within a single multiline commit. With a custom rule such as bump_pattern = &#34;^(break|oops)&#34;, bump_map = {&#34;break&#34;: &#34;MAJOR&#34;, &#34;oops&#34;: &#34;MINORR&#34;}, and a commit message like &#34;break: api\n\noops: typo&#34;, the previous find_increment() stopped scanning that commit as soon as the first line produced MAJOR, so the bump succeeded. This refactor keeps scanning later lines, so the same commit now raises ValueError on MINORR. That is a concrete regression in the existing custom bump_pattern/bump_map behavior the intent requires to preserve.

🔧 Fix applied.
1 error still open:

  • 🚨 commitizen/version_increment.py:64 - This fix round now accepts the string &#34;NONE&#34; as a matched bump_map output via return cls[value], which changes custom-plugin behavior instead of preserving it. With bump_pattern = &#34;^(docs)&#34;, bump_map = {&#34;docs&#34;: &#34;NONE&#34;}, and a commit like docs: update guide, the old find_increment() failed at VERSION_TYPES.index(&#34;NONE&#34;); this refactor now returns VersionIncrement.NONE, yielding a silent no-bump (or a PATCH under --allow-no-commit) without error. That contradicts the required intent to "preserve existing bump_pattern and bump_map custom-plugin behavior ... and current no-increment behavior without redesigning the rest of BumpRule." Because this extra acceptance was introduced by the review-round fix machinery rather than required for the minimal repair, the smallest honest remedy is to revert that new &#34;NONE&#34; acceptance and keep only the pre-existing no-increment cases (None / unmatched rules).

🔧 Fix applied.
✅ Re-checked - no issues remain.

🔧 **Test** - 1 issue found → no changes applied ✅
  • ⚠️ live validation verdict: inconclusive (6 of 7 scenarios were driven live against the product); untested: Scanning multiple commit messages compiles the bump pattern only once per scan
  • Live validation: ⚠️ inconclusive - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Custom bump_map invalid value matched during cz bump --allow-no-commit fails instead of silently patch-bumping ✅ pass live invalid custom bump_map failure log
A matched custom bump_map string value NONE is still rejected during cz bump --allow-no-commit ✅ pass live string NONE bump_map failure log
A multiline custom commit stops after a MAJOR match and still bumps to 1.0.0 even if a later line maps to an invalid increment ✅ pass live multiline major short-circuit log
cz version --project --next USE_GIT_COMMITS honors major_version_zero and turns a breaking change on 0.x into a minor bump ✅ pass live version next major-version-zero log
cz version --project --next USE_GIT_COMMITS preserves no-increment behavior for docs-only commits by leaving the version unchanged ✅ pass live version next docs no-increment log
A custom plugin's filter_commits_before_bump hook still limits both cz version --next USE_GIT_COMMITS and cz bump to the filtered commits ✅ pass live live plugin filter-hook log
Scanning multiple commit messages compiles the bump pattern only once per scan ⏸️ untested no The prior payload did not establish a live result for this scenario: it marked live=false and relied only on a targeted pytest regression for an internal implementation detail. To support a pass und…
  • python -m commitizen bump --yes --allow-no-commit in an isolated git repo configured with bump_map = { new = &#34;MINORR&#34;, fix = &#34;PATCH&#34; } after seeding tag 0.1.1
  • python -m commitizen bump --yes --allow-no-commit in an isolated git repo configured with bump_map = { docs = &#34;NONE&#34;, fix = &#34;PATCH&#34; } after seeding tag 0.1.1
  • python -m commitizen bump --yes in an isolated git repo with custom bump_pattern = &#34;^(break|oops)&#34; and a multiline commit message break: api\n\noops: typo
  • python -m commitizen version --project --next USE_GIT_COMMITS in an isolated git repo with major_version_zero = true and a feat!: breaking change commit after tag 0.1.0
  • python -m commitizen version --project --next USE_GIT_COMMITS in an isolated git repo with a docs-only commit after tag 1.0.0
  • python -m commitizen version --project --next USE_GIT_COMMITS and python -m commitizen bump --yes in an isolated git repo using a live custom plugin discovered from a temporary .dist-info entry point that filters commits before bumping
  • uv run pytest tests/test_version_increment.py::test_get_highest_by_messages_compiles_pattern_once tests/test_version_increment.py::test_get_highest_by_messages_rejects_string_none_mapping tests/test_version_increment.py::test_from_message_stops_after_major_match tests/test_version_increment.py::test_get_highest_by_messages_raises_for_invalid_bump_map_value tests/commands/test_bump_command.py::test_bump_allow_no_commit_with_invalid_custom_bump_map_raises tests/commands/test_bump_command.py::test_bump_allow_no_commit_with_string_none_bump_map_raises tests/commands/test_bump_command.py::test_bump_filters_commits_before_finding_increment tests/commands/test_version_command.py::test_version_next_use_git_commits_filters_before_finding_increment tests/commands/test_version_command.py::test_version_next_use_git_commits_major_version_zero

🔧 No changes applied.
✅ Re-checked - no issues remain.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Run cz bump --allow-no-commit after a docs-only commit and get a PATCH bump instead of a no-increment failure ✅ pass live live-scenario-no-increment-patch.log
Run cz bump --yes --allow-no-commit with a custom matched MINORR bump_map value and get an invalid-increment error with no fallback tag ✅ pass live live-scenario-invalid-minorr.log
Run cz bump --yes --allow-no-commit with a custom matched string NONE bump_map value and get the legacy invalid-increment error with no fallback tag ✅ pass live live-scenario-string-none-invalid.log
Run cz bump --yes on a multiline custom commit whose first matching line is MAJOR and a later line is invalid, and still get a MAJOR bump ✅ pass live live-scenario-major-short-circuit.log
Run cz version --project --next USE_GIT_COMMITS with major_version_zero = true and a breaking commit, and see 0.2.0 ✅ pass live live-scenario-version-major-version-zero.log
Run cz version --project --next USE_GIT_COMMITS with a plugin filter that keeps only AppA commits, and see the filtered PATCH version 1.0.1 ✅ pass live live-scenario-version-filter-hook.log
Scan multiple messages through VersionIncrement.get_highest_by_messages and observe result MAJOR with one bump-pattern compile ✅ pass live live-scenario-compile-once.log
  • PYTHONPATH=&#34;$PWD&#34; uv run --project &#34;$PWD&#34; python -m commitizen bump --yes and PYTHONPATH=&#34;$PWD&#34; uv run --project &#34;$PWD&#34; python -m commitizen bump --allow-no-commit in isolated git repos using conventional commits and cz_customize configs to exercise no-increment fallback, invalid custom bump_map values, and multiline custom MAJOR handling
  • PYTHONPATH=&#34;$PWD&#34; uv run --project &#34;$PWD&#34; python -m commitizen version --project --next USE_GIT_COMMITS in an isolated git repo with major_version_zero = true and a breaking commit
  • PYTHONPATH=&#34;$PWD&#34; uv run --project &#34;$PWD&#34; python - &lt;&lt;&#39;PY&#39; ... commitizen.cli.main() ... PY in an isolated git repo after temporarily registering a filtered plugin in commitizen.factory.registry to verify filter_commits_before_bump affects USE_GIT_COMMITS version calculation
  • PYTHONPATH=&#34;$PWD&#34; uv run --project &#34;$PWD&#34; python - &lt;&lt;&#39;PY&#39; ... VersionIncrement.get_highest_by_messages(...) ... PY with temporary commitizen.version_increment.re.compile instrumentation to verify the highest increment stays MAJOR while the bump pattern compiles once
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@github-actions

Copy link
Copy Markdown
Contributor

🔍 Commitizen bump preview

Merging this PR will produce the following bump:

bump: version 4.19.0 → 4.19.1
tag to create: v4.19.1
increment detected: PATCH

## v4.19.1 (2026-09-30)

### Refactor

- **bump**: replace find_increment with VersionIncrement max

@codecov

codecov Bot commented Sep 30, 2026

Copy link
Copy Markdown

⚠️ JUnit XML file not found

The CLI was unable to find any JUnit XML files to upload.
For more help, visit our troubleshooting guide.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant