diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index f05bc23c1..9127f8eb4 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -109,13 +109,7 @@ def __call__(self) -> None: tag_format = self._ask_tag_format(tag) # confirm & text update_changelog_on_bump = self._ask_update_changelog_on_bump() # confirm major_version_zero = self._ask_major_version_zero(version) # confirm - hook_types: list[str] | None = questionary.checkbox( - "What types of pre-commit hook you want to install? (Leave blank if you don't want to install)", - choices=[ - questionary.Choice("commit-msg", checked=False), - questionary.Choice("pre-push", checked=False), - ], - ).unsafe_ask() + hook_types = self._ask_hook_types() except KeyboardInterrupt: raise InitFailedError("Stopped by user") @@ -128,13 +122,9 @@ def __call__(self) -> None: ) as config_file: yaml.safe_dump(config_data, stream=config_file) - if not project_info.is_pre_commit_installed(): - raise InitFailedError( - "Failed to install pre-commit hook.\n" - "pre-commit is not installed in current environment." - ) + installer = self._ask_hook_installer() - cmd_args = ["pre-commit", "install"] + cmd_args = [installer, "install"] for ty in hook_types: cmd_args.extend(["--hook-type", ty]) c = cmd.run(cmd_args) @@ -164,6 +154,48 @@ def __call__(self) -> None: out.info("\tcz bump\n") out.success("Configuration complete 🚀") + def _ask_hook_types(self) -> list[str] | None: + """Ask which pre-commit hook types to install. + + Skip the question when neither ``pre-commit`` nor ``prek`` is + installed, so users who do not use those tools are not prompted. + """ + if not project_info.available_hook_installers(): + out.info("No pre-commit hook detected, skipping question") + return None + + hook_types: list[str] | None = questionary.checkbox( + "What types of pre-commit hook you want to install? (Leave blank if you don't want to install)", + choices=[ + questionary.Choice("commit-msg", checked=False), + questionary.Choice("pre-push", checked=False), + ], + ).unsafe_ask() + return hook_types + + def _ask_hook_installer(self) -> str: + """Choose ``pre-commit`` or ``prek`` when installing Git hooks. + + Detection already accepts either tool, but install used to + hard-code ``pre-commit``. Use the only available installer, or + ask when both are on PATH. + """ + installers = project_info.available_hook_installers() + if not installers: + raise InitFailedError( + "Failed to install pre-commit hook.\n" + "Neither pre-commit nor prek is installed in the current environment." + ) + if len(installers) == 1: + return installers[0] + + installer: str = questionary.select( + "Which hook installer do you want to use?", + choices=installers, + style=self.cz.style, + ).unsafe_ask() + return installer + def _ask_config_path(self) -> Path: filename: str = questionary.select( "Please choose a supported config file: ", diff --git a/commitizen/project_info.py b/commitizen/project_info.py index a85970133..4f5854c3b 100644 --- a/commitizen/project_info.py +++ b/commitizen/project_info.py @@ -4,9 +4,22 @@ from pathlib import Path from typing import Literal +_HOOK_INSTALLERS = ("pre-commit", "prek") + + +def available_hook_installers() -> list[str]: + """Return hook installer CLIs found on PATH. + + ``pre-commit`` and ``prek`` are interchangeable. ``pre-commit`` is + listed first when both are present so existing setups keep a stable + default unless the user is asked to choose. + """ + return [tool for tool in _HOOK_INSTALLERS if shutil.which(tool)] + def is_pre_commit_installed() -> bool: - return any(shutil.which(tool) for tool in ("pre-commit", "prek")) + """Return whether any supported hook installer is on PATH.""" + return bool(available_hook_installers()) def get_default_version_provider() -> Literal[ diff --git a/docs/commands/init.md b/docs/commands/init.md index 122e1bef5..dabcb4396 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -43,7 +43,7 @@ During the initialization process, you'll be prompted to configure the following - `pep440`: Python Package Versioning 6. **Changelog Generation**: Configure whether to automatically generate changelog during version bumps 7. **Alpha Versioning**: Option to keep major version at 0 for alpha/beta software -8. **Pre-commit Hooks**: Set up Git pre-commit hooks for automated commit message validation +8. **Pre-commit Hooks**: Set up Git hooks for automated commit message validation. If neither `pre-commit` nor `prek` is on PATH, the hook question is skipped. If you choose to install hooks, Commitizen uses whichever of those tools is available. If both are installed, you are asked which one to use. See [Configuration Options][configuration_options] for more details. diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index db47fd064..6dc17a8f8 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -121,10 +121,10 @@ def test_init_without_choosing_tag( @pytest.fixture def pre_commit_installed(mocker: MockFixture): - # Assume the `pre-commit` is installed + # Assume only `pre-commit` is installed mocker.patch( - "commitizen.project_info.is_pre_commit_installed", - return_value=True, + "commitizen.project_info.available_hook_installers", + return_value=["pre-commit"], ) # And installation success (i.e. no exception raised) mocker.patch( @@ -228,19 +228,162 @@ def test_cz_hook_exists_in_pre_commit_config( class TestNoPreCommitInstalled: - @pytest.mark.usefixtures("default_choice") - def test_pre_commit_not_installed( + def test_skips_hook_question_when_neither_installer_is_installed( + self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch, capsys + ): + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("commitizen"), + FakeQuestion("semver"), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + checkbox = mocker.patch("questionary.checkbox") + mocker.patch( + "commitizen.project_info.available_hook_installers", + return_value=[], + ) + monkeypatch.chdir(tmp_path) + + commands.Init(config)() + + checkbox.assert_not_called() + captured = capsys.readouterr() + assert "No pre-commit hook detected, skipping question" in captured.out + assert Path("pyproject.toml").read_text(encoding="utf-8") == expected_config + assert not Path(pre_commit_config_filename).exists() + + +def _init_hook_answers(mocker: MockFixture) -> None: + """Stub the interactive init prompts and select hook installation.""" + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("commitizen"), + FakeQuestion("semver"), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + mocker.patch( + "questionary.checkbox", + return_value=FakeQuestion(["commit-msg", "pre-push"]), + ) + + +class TestHookInstallerSelection: + def test_uses_prek_when_only_prek_is_installed( + self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch + ): + _init_hook_answers(mocker) + mocker.patch( + "commitizen.project_info.available_hook_installers", + return_value=["prek"], + ) + run = mocker.patch( + "commitizen.cmd.run", + return_value=cmd.Command("", "", b"", b"", 0), + ) + monkeypatch.chdir(tmp_path) + + commands.Init(config)() + + run.assert_any_call( + ["prek", "install", "--hook-type", "commit-msg", "--hook-type", "pre-push"] + ) + + def test_asks_when_both_installers_are_present( self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch ): - # Assume `pre-commit` is not installed mocker.patch( - "commitizen.project_info.is_pre_commit_installed", - return_value=False, + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("commitizen"), + FakeQuestion("semver"), + FakeQuestion("prek"), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + mocker.patch( + "questionary.checkbox", + return_value=FakeQuestion(["commit-msg"]), + ) + mocker.patch( + "commitizen.project_info.available_hook_installers", + return_value=["pre-commit", "prek"], + ) + run = mocker.patch( + "commitizen.cmd.run", + return_value=cmd.Command("", "", b"", b"", 0), ) monkeypatch.chdir(tmp_path) + + commands.Init(config)() + + run.assert_any_call(["prek", "install", "--hook-type", "commit-msg"]) + + def test_uses_pre_commit_when_only_pre_commit_is_installed( + self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch + ): + _init_hook_answers(mocker) + mocker.patch( + "commitizen.project_info.available_hook_installers", + return_value=["pre-commit"], + ) + run = mocker.patch( + "commitizen.cmd.run", + return_value=cmd.Command("", "", b"", b"", 0), + ) + monkeypatch.chdir(tmp_path) + + commands.Init(config)() + + run.assert_any_call( + [ + "pre-commit", + "install", + "--hook-type", + "commit-msg", + "--hook-type", + "pre-push", + ] + ) + + def test_fails_when_installer_disappears_between_prompt_and_install( + self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch + ): + _init_hook_answers(mocker) + # First call (during _ask_hook_types) finds pre-commit; second call + # (during _ask_hook_installer, after the config is written) finds none, + # e.g. the tool was uninstalled or the PATH changed in between. + mocker.patch( + "commitizen.project_info.available_hook_installers", + side_effect=[["pre-commit"], []], + ) + run = mocker.patch( + "commitizen.cmd.run", + return_value=cmd.Command("", "", b"", b"", 0), + ) + monkeypatch.chdir(tmp_path) + with pytest.raises(InitFailedError): commands.Init(config)() + # Other subprocess calls (git describe, git config) may still happen, + # but no hook installer may have been invoked. + assert not any( + "--hook-type" in " ".join(call.args[0]) for call in run.call_args_list + ) + class TestAskTagFormat: def test_confirm_v_tag_format(self, mocker: MockFixture, config: BaseConfig): diff --git a/tests/test_project_info.py b/tests/test_project_info.py index 4ab704445..afc91f3c1 100644 --- a/tests/test_project_info.py +++ b/tests/test_project_info.py @@ -19,17 +19,26 @@ def _create_project_files(files: dict[str, str | None]) -> None: @pytest.mark.parametrize( - ("which_return", "expected"), + ("which_map", "expected"), [ - ("/usr/local/bin/pre-commit", True), - ("/usr/local/bin/prek", True), - (None, False), - ("", False), + ({"pre-commit": "/usr/local/bin/pre-commit"}, ["pre-commit"]), + ({"prek": "/usr/local/bin/prek"}, ["prek"]), + ( + { + "pre-commit": "/usr/local/bin/pre-commit", + "prek": "/usr/local/bin/prek", + }, + ["pre-commit", "prek"], + ), + ({}, []), + ({"pre-commit": "", "prek": None}, []), ], ) -def test_is_pre_commit_installed(mocker, which_return, expected): - mocker.patch("shutil.which", return_value=which_return) - assert project_info.is_pre_commit_installed() is expected +def test_available_hook_installers(mocker, which_map, expected): + mocker.patch("shutil.which", side_effect=lambda name: which_map.get(name)) + + assert project_info.available_hook_installers() == expected + assert project_info.is_pre_commit_installed() is bool(expected) @pytest.mark.parametrize(