Skip to content

Commit 7591d04

Browse files
committed
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
1 parent 8296fde commit 7591d04

5 files changed

Lines changed: 164 additions & 22 deletions

File tree

commitizen/commands/init.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,9 @@ def __call__(self) -> None:
128128
) as config_file:
129129
yaml.safe_dump(config_data, stream=config_file)
130130

131-
if not project_info.is_pre_commit_installed():
132-
raise InitFailedError(
133-
"Failed to install pre-commit hook.\n"
134-
"pre-commit is not installed in current environment."
135-
)
131+
installer = self._ask_hook_installer()
136132

137-
cmd_args = ["pre-commit", "install"]
133+
cmd_args = [installer, "install"]
138134
for ty in hook_types:
139135
cmd_args.extend(["--hook-type", ty])
140136
c = cmd.run(cmd_args)
@@ -164,6 +160,29 @@ def __call__(self) -> None:
164160
out.info("\tcz bump\n")
165161
out.success("Configuration complete 🚀")
166162

163+
def _ask_hook_installer(self) -> str:
164+
"""Choose ``pre-commit`` or ``prek`` when installing Git hooks.
165+
166+
Detection already accepts either tool, but install used to
167+
hard-code ``pre-commit``. Use the only available installer, or
168+
ask when both are on PATH.
169+
"""
170+
installers = project_info.available_hook_installers()
171+
if not installers:
172+
raise InitFailedError(
173+
"Failed to install pre-commit hook.\n"
174+
"Neither pre-commit nor prek is installed in the current environment."
175+
)
176+
if len(installers) == 1:
177+
return installers[0]
178+
179+
installer: str = questionary.select(
180+
"Which hook installer do you want to use?",
181+
choices=installers,
182+
style=self.cz.style,
183+
).unsafe_ask()
184+
return installer
185+
167186
def _ask_config_path(self) -> Path:
168187
filename: str = questionary.select(
169188
"Please choose a supported config file: ",

commitizen/project_info.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,22 @@
44
from pathlib import Path
55
from typing import Literal
66

7+
_HOOK_INSTALLERS = ("pre-commit", "prek")
8+
9+
10+
def available_hook_installers() -> list[str]:
11+
"""Return hook installer CLIs found on PATH.
12+
13+
``pre-commit`` and ``prek`` are interchangeable. ``pre-commit`` is
14+
listed first when both are present so existing setups keep a stable
15+
default unless the user is asked to choose.
16+
"""
17+
return [tool for tool in _HOOK_INSTALLERS if shutil.which(tool)]
18+
719

820
def is_pre_commit_installed() -> bool:
9-
return any(shutil.which(tool) for tool in ("pre-commit", "prek"))
21+
"""Return whether any supported hook installer is on PATH."""
22+
return bool(available_hook_installers())
1023

1124

1225
def get_default_version_provider() -> Literal[

docs/commands/init.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ During the initialization process, you'll be prompted to configure the following
4343
- `pep440`: Python Package Versioning
4444
6. **Changelog Generation**: Configure whether to automatically generate changelog during version bumps
4545
7. **Alpha Versioning**: Option to keep major version at 0 for alpha/beta software
46-
8. **Pre-commit Hooks**: Set up Git pre-commit hooks for automated commit message validation
46+
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.
4747

4848
See [Configuration Options][configuration_options] for more details.
4949

tests/commands/test_init_command.py

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,10 @@ def test_init_without_choosing_tag(
121121

122122
@pytest.fixture
123123
def pre_commit_installed(mocker: MockFixture):
124-
# Assume the `pre-commit` is installed
124+
# Assume only `pre-commit` is installed
125125
mocker.patch(
126-
"commitizen.project_info.is_pre_commit_installed",
127-
return_value=True,
126+
"commitizen.project_info.available_hook_installers",
127+
return_value=["pre-commit"],
128128
)
129129
# And installation success (i.e. no exception raised)
130130
mocker.patch(
@@ -232,16 +232,117 @@ class TestNoPreCommitInstalled:
232232
def test_pre_commit_not_installed(
233233
self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch
234234
):
235-
# Assume `pre-commit` is not installed
235+
# Assume neither `pre-commit` nor `prek` is installed
236236
mocker.patch(
237-
"commitizen.project_info.is_pre_commit_installed",
238-
return_value=False,
237+
"commitizen.project_info.available_hook_installers",
238+
return_value=[],
239239
)
240240
monkeypatch.chdir(tmp_path)
241241
with pytest.raises(InitFailedError):
242242
commands.Init(config)()
243243

244244

245+
def _init_hook_answers(mocker: MockFixture) -> None:
246+
"""Stub the interactive init prompts and select hook installation."""
247+
mocker.patch(
248+
"questionary.select",
249+
side_effect=[
250+
FakeQuestion("pyproject.toml"),
251+
FakeQuestion("cz_conventional_commits"),
252+
FakeQuestion("commitizen"),
253+
FakeQuestion("semver"),
254+
],
255+
)
256+
mocker.patch("questionary.confirm", return_value=FakeQuestion(True))
257+
mocker.patch("questionary.text", return_value=FakeQuestion("$version"))
258+
mocker.patch(
259+
"questionary.checkbox",
260+
return_value=FakeQuestion(["commit-msg", "pre-push"]),
261+
)
262+
263+
264+
class TestHookInstallerSelection:
265+
def test_uses_prek_when_only_prek_is_installed(
266+
self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch
267+
):
268+
_init_hook_answers(mocker)
269+
mocker.patch(
270+
"commitizen.project_info.available_hook_installers",
271+
return_value=["prek"],
272+
)
273+
run = mocker.patch(
274+
"commitizen.cmd.run",
275+
return_value=cmd.Command("", "", b"", b"", 0),
276+
)
277+
monkeypatch.chdir(tmp_path)
278+
279+
commands.Init(config)()
280+
281+
run.assert_any_call(
282+
["prek", "install", "--hook-type", "commit-msg", "--hook-type", "pre-push"]
283+
)
284+
285+
def test_asks_when_both_installers_are_present(
286+
self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch
287+
):
288+
mocker.patch(
289+
"questionary.select",
290+
side_effect=[
291+
FakeQuestion("pyproject.toml"),
292+
FakeQuestion("cz_conventional_commits"),
293+
FakeQuestion("commitizen"),
294+
FakeQuestion("semver"),
295+
FakeQuestion("prek"),
296+
],
297+
)
298+
mocker.patch("questionary.confirm", return_value=FakeQuestion(True))
299+
mocker.patch("questionary.text", return_value=FakeQuestion("$version"))
300+
mocker.patch(
301+
"questionary.checkbox",
302+
return_value=FakeQuestion(["commit-msg"]),
303+
)
304+
mocker.patch(
305+
"commitizen.project_info.available_hook_installers",
306+
return_value=["pre-commit", "prek"],
307+
)
308+
run = mocker.patch(
309+
"commitizen.cmd.run",
310+
return_value=cmd.Command("", "", b"", b"", 0),
311+
)
312+
monkeypatch.chdir(tmp_path)
313+
314+
commands.Init(config)()
315+
316+
run.assert_any_call(["prek", "install", "--hook-type", "commit-msg"])
317+
318+
def test_uses_pre_commit_when_only_pre_commit_is_installed(
319+
self, mocker: MockFixture, config: BaseConfig, tmp_path, monkeypatch
320+
):
321+
_init_hook_answers(mocker)
322+
mocker.patch(
323+
"commitizen.project_info.available_hook_installers",
324+
return_value=["pre-commit"],
325+
)
326+
run = mocker.patch(
327+
"commitizen.cmd.run",
328+
return_value=cmd.Command("", "", b"", b"", 0),
329+
)
330+
monkeypatch.chdir(tmp_path)
331+
332+
commands.Init(config)()
333+
334+
run.assert_any_call(
335+
[
336+
"pre-commit",
337+
"install",
338+
"--hook-type",
339+
"commit-msg",
340+
"--hook-type",
341+
"pre-push",
342+
]
343+
)
344+
345+
245346
class TestAskTagFormat:
246347
def test_confirm_v_tag_format(self, mocker: MockFixture, config: BaseConfig):
247348
init = commands.Init(config)

tests/test_project_info.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,26 @@ def _create_project_files(files: dict[str, str | None]) -> None:
1919

2020

2121
@pytest.mark.parametrize(
22-
("which_return", "expected"),
22+
("which_map", "expected"),
2323
[
24-
("/usr/local/bin/pre-commit", True),
25-
("/usr/local/bin/prek", True),
26-
(None, False),
27-
("", False),
24+
({"pre-commit": "/usr/local/bin/pre-commit"}, ["pre-commit"]),
25+
({"prek": "/usr/local/bin/prek"}, ["prek"]),
26+
(
27+
{
28+
"pre-commit": "/usr/local/bin/pre-commit",
29+
"prek": "/usr/local/bin/prek",
30+
},
31+
["pre-commit", "prek"],
32+
),
33+
({}, []),
34+
({"pre-commit": "", "prek": None}, []),
2835
],
2936
)
30-
def test_is_pre_commit_installed(mocker, which_return, expected):
31-
mocker.patch("shutil.which", return_value=which_return)
32-
assert project_info.is_pre_commit_installed() is expected
37+
def test_available_hook_installers(mocker, which_map, expected):
38+
mocker.patch("shutil.which", side_effect=lambda name: which_map.get(name))
39+
40+
assert project_info.available_hook_installers() == expected
41+
assert project_info.is_pre_commit_installed() is bool(expected)
3342

3443

3544
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)