Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 45 additions & 13 deletions commitizen/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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)
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the user doesn't have any installed and doesn't want to use any of these? Like myself.

Maybe if nothing is installed, it should prompt:

No pre-commit hook detected, skipping question

so users who want this know how to act accordingly (install pre-commit and retry).

What do you think?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — if neither pre-commit nor prek is installed we skip the question now and print that message.

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: ",
Expand Down
15 changes: 14 additions & 1 deletion commitizen/project_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
159 changes: 151 additions & 8 deletions tests/commands/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down
25 changes: 17 additions & 8 deletions tests/test_project_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading