From 7591d04b331e32f965eca32dbc917da00fbe40f3 Mon Sep 17 00:00:00 2001 From: SeaStar Deng <37767638+DSeaStar@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:59:51 +0000 Subject: [PATCH 1/3] fix(init): use prek when installing hooks if pre-commit is missing cz init already treated prek as installed, but always ran `pre-commit install`, which raised FileNotFoundError when only prek was on PATH. Use the available installer, and ask when both exist. Fixes #2018 --- commitizen/commands/init.py | 31 ++++++-- commitizen/project_info.py | 15 +++- docs/commands/init.md | 2 +- tests/commands/test_init_command.py | 113 ++++++++++++++++++++++++++-- tests/test_project_info.py | 25 ++++-- 5 files changed, 164 insertions(+), 22 deletions(-) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index f05bc23c1..a94e9f2ba 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -128,13 +128,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 +160,29 @@ def __call__(self) -> None: out.info("\tcz bump\n") out.success("Configuration complete 🚀") + 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..13e5c9566 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 you choose to install hooks, Commitizen uses `pre-commit` or `prek` (whichever is on PATH). 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..c4daff83c 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( @@ -232,16 +232,117 @@ class TestNoPreCommitInstalled: def test_pre_commit_not_installed( self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch ): - # Assume `pre-commit` is not installed + # Assume neither `pre-commit` nor `prek` is installed mocker.patch( - "commitizen.project_info.is_pre_commit_installed", - return_value=False, + "commitizen.project_info.available_hook_installers", + return_value=[], ) monkeypatch.chdir(tmp_path) with pytest.raises(InitFailedError): commands.Init(config)() +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 + ): + mocker.patch( + "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", + ] + ) + + class TestAskTagFormat: def test_confirm_v_tag_format(self, mocker: MockFixture, config: BaseConfig): init = commands.Init(config) 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( From c9ea08e8f8b2990164db2db99e26df5180faa1b9 Mon Sep 17 00:00:00 2001 From: SeaStar Deng <37767638+DSeaStar@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:56:19 +0000 Subject: [PATCH 2/3] fix(init): skip hook question when no installer is present If neither pre-commit nor prek is on PATH, skip the hook-type question instead of failing init. Users who want hooks can install a tool and retry. --- commitizen/commands/init.py | 27 +++++++++++++++++++------- docs/commands/init.md | 2 +- tests/commands/test_init_command.py | 30 ++++++++++++++++++++++------- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index a94e9f2ba..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") @@ -160,6 +154,25 @@ 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. diff --git a/docs/commands/init.md b/docs/commands/init.md index 13e5c9566..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 hooks for automated commit message validation. If you choose to install hooks, Commitizen uses `pre-commit` or `prek` (whichever is on PATH). If both are installed, you are asked which one to use. +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 c4daff83c..a0f325790 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -9,7 +9,7 @@ from commitizen import cmd, commands from commitizen.__version__ import __version__ -from commitizen.exceptions import InitFailedError, NoAnswersError +from commitizen.exceptions import NoAnswersError if TYPE_CHECKING: from pytest_mock import MockFixture @@ -228,18 +228,34 @@ def test_cz_hook_exists_in_pre_commit_config( class TestNoPreCommitInstalled: - @pytest.mark.usefixtures("default_choice") - def test_pre_commit_not_installed( - self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch + def test_skips_hook_question_when_neither_installer_is_installed( + self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch, capsys ): - # Assume neither `pre-commit` nor `prek` is installed + 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) - with pytest.raises(InitFailedError): - commands.Init(config)() + + 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: From bb343bc8a2b091de1054d754f70966bbb104ab51 Mon Sep 17 00:00:00 2001 From: DSeaStar <172368758@qq.com> Date: Sat, 15 Aug 2026 01:27:14 +0800 Subject: [PATCH 3/3] test(init): cover installer missing at install time The InitFailedError branch in _ask_hook_installer was flagged by codecov as the only uncovered line of the PR. Add a test where the installer is available during the hook-type question but disappears before the install step, so the guard is exercised instead of removed. --- tests/commands/test_init_command.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index a0f325790..6dc17a8f8 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -9,7 +9,7 @@ from commitizen import cmd, commands from commitizen.__version__ import __version__ -from commitizen.exceptions import NoAnswersError +from commitizen.exceptions import InitFailedError, NoAnswersError if TYPE_CHECKING: from pytest_mock import MockFixture @@ -358,6 +358,32 @@ def test_uses_pre_commit_when_only_pre_commit_is_installed( ] ) + 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):