diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 0b6e0ffa3..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,23 +387,33 @@ 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) - 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}"') + if updated_files: + _stage_updated_files(updated_files) - 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") + _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 + 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..7946cb465 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..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, @@ -1375,6 +1376,76 @@ 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( + 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 + ``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") + add_mock = mocker.patch("commitizen.git.add") + + 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 + 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