From 16b3318a41fb28136f0ebc0dc92bf10cef99faf6 Mon Sep 17 00:00:00 2001 From: Tim Hsiung Date: Sat, 9 May 2026 19:15:40 +0800 Subject: [PATCH 1/3] fix(bump): skip commit step when there are no files to commit When using `version_provider = "scm"` with no `version_files` and no `--changelog` / `update_changelog_on_bump`, the bump has nothing to commit. `cz bump` would then call `git commit -a`, which exits with `nothing to commit, working tree clean` and the bump fails with `BumpCommitFailedError`. Add a `git.has_pending_changes()` helper that returns `True` only if `git commit -a` would actually commit something, and use it to decide whether to issue the bump commit. When there is nothing to commit, the tag is created on `HEAD` directly. This is the typical flow for SCM- driven projects that derive the version from the latest tag. Closes #1530 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- commitizen/commands/bump.py | 38 ++++++++++++++++++----------- commitizen/git.py | 12 +++++++++ tests/commands/test_bump_command.py | 32 ++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 0b6e0ffa3..24394315f 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -382,21 +382,31 @@ def __call__(self) -> None: # FIXME: check if any changes have been staged git.add(*updated_files) - c = git.commit(message, args=self._get_commit_args()) - if self.retry and c.return_code != 0 and self.changelog_flag: - # Maybe pre-commit reformatted some files? Retry once - logger.debug("1st git.commit error: %s", c.err) - logger.info("1st commit attempt failed; retrying once") - git.add(*updated_files) - c = git.commit(message, args=self._get_commit_args()) - if c.return_code != 0: - err = c.err.strip() or c.out - raise BumpCommitFailedError(f'2nd git.commit error: "{err}"') - for msg in (c.out, c.err): - if msg: - out_func = out.diagnostic if self.git_output_to_stderr else out.write - out_func(msg) + # When there is nothing for the bump to commit (e.g. ``version_provider + # = "scm"`` with no ``version_files`` and no ``--changelog``), skip the + # commit step and just tag ``HEAD``. Calling ``git commit`` here would + # fail with ``nothing to commit, working tree clean`` (#1530). + if not git.has_pending_changes(): + out.info("No file changes; skipping bump commit and tagging HEAD.") + else: + c = git.commit(message, args=self._get_commit_args()) + if self.retry and c.return_code != 0 and self.changelog_flag: + # Maybe pre-commit reformatted some files? Retry once + logger.debug("1st git.commit error: %s", c.err) + logger.info("1st commit attempt failed; retrying once") + git.add(*updated_files) + c = git.commit(message, args=self._get_commit_args()) + if c.return_code != 0: + err = c.err.strip() or c.out + raise BumpCommitFailedError(f'2nd git.commit error: "{err}"') + + for msg in (c.out, c.err): + if msg: + out_func = ( + out.diagnostic if self.git_output_to_stderr else out.write + ) + out_func(msg) c = git.tag( new_tag_version, diff --git a/commitizen/git.py b/commitizen/git.py index ce9f440c9..6f94c4099 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -311,6 +311,18 @@ def is_staging_clean() -> bool: return not bool(c.out) +def has_pending_changes() -> bool: + """Check whether there are any tracked-file changes for `git commit -a` to commit. + + Returns ``True`` if there are staged or unstaged modifications to tracked + files, ``False`` if the working tree is clean for tracked files. Untracked + files are intentionally ignored — they would be ignored by ``git commit -a`` + too. + """ + c = cmd.run(["git", "status", "--porcelain", "--untracked-files=no"]) + return bool(c.out.strip()) + + def is_git_project() -> bool: c = cmd.run(["git", "rev-parse", "--is-inside-work-tree"]) return c.out.strip() == "true" diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 1e5a162ba..d2e9c915a 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1375,6 +1375,38 @@ def test_bump_warn_but_dont_fail_on_invalid_tags( assert git.tag_exist("0.4.3") is True +def test_bump_skips_commit_when_no_files_changed( + tmp_commitizen_project: Path, util: UtilFixture +): + """Regression test for #1530: with ``version_provider = "scm"`` and no + ``version_files`` / ``--changelog``, ``cz bump`` should not fail with + ``nothing to commit, working tree clean`` -- it should just create the + tag on ``HEAD``. + """ + project_root = tmp_commitizen_project + tmp_commitizen_cfg_file = project_root / "pyproject.toml" + tmp_commitizen_cfg_file.write_text( + "\n".join( + [ + "[tool.commitizen]", + 'version_provider = "scm"', + 'tag_format = "v$version"', + ] + ), + ) + util.create_file_and_commit("feat: first feature") + + util.run_cli("bump", "--yes") + + assert git.tag_exist("v0.1.0") is True + + util.create_file_and_commit("fix: second change") + + util.run_cli("bump", "--yes") + + assert git.tag_exist("v0.1.1") is True + + def test_is_initial_tag(mocker: MockFixture, tmp_commitizen_project, util: UtilFixture): """Test the _is_initial_tag method behavior.""" # Create a commit but no tags From 53dd1cbb61092dfb273e6f4cb30028c0c24fd07a Mon Sep 17 00:00:00 2001 From: Tim Hsiung <26526132+bearomorphism@users.noreply.github.com> Date: Sat, 9 May 2026 22:30:15 +0800 Subject: [PATCH 2/3] fix(bump): address PR #1969 reviewer feedback * skip git.add when updated_files is empty * remove now-misleading FIXME on has_pending_changes guard * use consistent double-backtick reST formatting in has_pending_changes docstring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- commitizen/commands/bump.py | 13 ++++++++++--- commitizen/git.py | 2 +- tests/commands/test_bump_command.py | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 24394315f..13b9dbac2 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -62,6 +62,13 @@ class BumpArgs(Settings, total=False): yes: bool +def _stage_updated_files(updated_files: list[str]) -> None: + c = git.add(*updated_files) + if c.return_code != 0: + err = c.err.strip() or c.out + raise BumpCommitFailedError(f'git.add error: "{err}"') + + class Bump: """Show prompt for the user to create a guided commit.""" @@ -380,8 +387,8 @@ def __call__(self) -> None: if self.arguments.get("version_files_only"): raise ExpectedExit() - # FIXME: check if any changes have been staged - git.add(*updated_files) + if updated_files: + _stage_updated_files(updated_files) # When there is nothing for the bump to commit (e.g. ``version_provider # = "scm"`` with no ``version_files`` and no ``--changelog``), skip the @@ -395,7 +402,7 @@ def __call__(self) -> None: # Maybe pre-commit reformatted some files? Retry once logger.debug("1st git.commit error: %s", c.err) logger.info("1st commit attempt failed; retrying once") - git.add(*updated_files) + _stage_updated_files(updated_files) c = git.commit(message, args=self._get_commit_args()) if c.return_code != 0: err = c.err.strip() or c.out diff --git a/commitizen/git.py b/commitizen/git.py index 6f94c4099..7946cb465 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -312,7 +312,7 @@ def is_staging_clean() -> bool: def has_pending_changes() -> bool: - """Check whether there are any tracked-file changes for `git commit -a` to commit. + """Check whether there are any tracked-file changes for ``git commit -a`` to commit. Returns ``True`` if there are staged or unstaged modifications to tracked files, ``False`` if the working tree is clean for tracked files. Untracked diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index d2e9c915a..6170405c0 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1376,7 +1376,7 @@ def test_bump_warn_but_dont_fail_on_invalid_tags( def test_bump_skips_commit_when_no_files_changed( - tmp_commitizen_project: Path, util: UtilFixture + mocker: MockFixture, tmp_commitizen_project: Path, util: UtilFixture ): """Regression test for #1530: with ``version_provider = "scm"`` and no ``version_files`` / ``--changelog``, ``cz bump`` should not fail with @@ -1395,6 +1395,7 @@ def test_bump_skips_commit_when_no_files_changed( ), ) util.create_file_and_commit("feat: first feature") + add_mock = mocker.patch("commitizen.git.add") util.run_cli("bump", "--yes") @@ -1405,6 +1406,7 @@ def test_bump_skips_commit_when_no_files_changed( util.run_cli("bump", "--yes") assert git.tag_exist("v0.1.1") is True + add_mock.assert_not_called() def test_is_initial_tag(mocker: MockFixture, tmp_commitizen_project, util: UtilFixture): From c04048c650e511922528fe62a27391f2ab8b0fef Mon Sep 17 00:00:00 2001 From: Tim Hsiung <26526132+bearomorphism@users.noreply.github.com> Date: Sat, 9 May 2026 23:06:42 +0800 Subject: [PATCH 3/3] test(bump): cover _stage_updated_files error path The git.add return-code check added in the previous reviewer-feedback commit was not exercised by tests. This adds a regression test that mocks git.add returning non-zero and asserts BumpCommitFailedError is raised with a useful message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/commands/test_bump_command.py | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 6170405c0..8e548b7cf 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -13,6 +13,7 @@ from commitizen import cmd, defaults, git, hooks from commitizen.config.base_config import BaseConfig from commitizen.exceptions import ( + BumpCommitFailedError, BumpTagFailedError, CommitizenException, CurrentVersionNotFoundError, @@ -1409,6 +1410,42 @@ def test_bump_skips_commit_when_no_files_changed( add_mock.assert_not_called() +def test_bump_raises_when_git_add_fails( + mocker: MockFixture, tmp_commitizen_project: Path, util: UtilFixture +): + """Regression test: ``cz bump`` reports a failing ``git.add``.""" + project_root = tmp_commitizen_project + tmp_commitizen_cfg_file = project_root / "pyproject.toml" + tmp_commitizen_cfg_file.write_text( + "\n".join( + [ + "[tool.commitizen]", + 'version = "0.1.0"', + 'tag_format = "v$version"', + 'version_files = ["pyproject.toml:version"]', + ] + ), + ) + util.create_file_and_commit("feat: first feature") + + mocker.patch( + "commitizen.git.add", + return_value=cmd.Command( + out="", + err="fatal: pathspec did not match any files", + stdout=b"", + stderr=b"fatal: pathspec did not match any files", + return_code=128, + ), + ) + + with pytest.raises(BumpCommitFailedError) as exc_info: + util.run_cli("bump", "--yes") + + assert "git.add error" in str(exc_info.value) + assert "fatal: pathspec" in str(exc_info.value) + + def test_is_initial_tag(mocker: MockFixture, tmp_commitizen_project, util: UtilFixture): """Test the _is_initial_tag method behavior.""" # Create a commit but no tags