blob: 6d24e05c73785ac19cd04513752d9a255dac5d63 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for ``sync-hook-managers.py``.
The committed tree must be in sync, a registry that breaks a contract rule is
refused, and a stale block is rewritten (or reported under ``--check``).
The script's filename is hyphenated, which is not an importable module name, so
it is loaded through ``importlib.util``.
"""
from __future__ import annotations
import copy
import importlib.util
import json
import shutil
from pathlib import Path
from types import ModuleType
from typing import Any
import pytest
_SCRIPT = Path(__file__).resolve().parents[1] / "sync-hook-managers.py"
def _load() -> ModuleType:
spec = importlib.util.spec_from_file_location("sync_hook_managers", _SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
mod = _load()
@pytest.fixture
def registry() -> dict[str, Any]:
return json.loads((mod.REPO_ROOT / mod.REGISTRY).read_text(encoding="utf-8"))
@pytest.fixture
def tree(tmp_path: Path) -> Path:
"""A copy of every file the script reads or writes."""
for rel in (mod.REGISTRY, mod.SHIM, mod.DISPATCHER, mod.SANDBOX_LINT, *mod.SETTINGS):
(tmp_path / rel).parent.mkdir(parents=True, exist_ok=True)
shutil.copy(mod.REPO_ROOT / rel, tmp_path / rel)
(tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") # this repository uses prek
return tmp_path
def test_the_committed_tree_is_in_sync() -> None:
assert mod.main(["--check"]) == 0
def test_the_committed_registry_follows_the_contract(registry: dict[str, Any]) -> None:
assert mod.validate(registry) == []
@pytest.mark.parametrize(
("path", "value", "message"),
[
(("install", "strategy"), "not-a-strategy", "install.strategy"),
(("install", "subcommand"), "install --now", "install.subcommand"),
(("install", "runtime_env"), "yes", "install.runtime_env"),
(("detect", "config_files"), [], "detect.config_files"),
(("detect", "config_files"), ["sub/prek.toml"], "detect.config_files"),
(("detect", "nested"), "yes", "detect.nested"),
(("detect", "hook_marker"), "", "detect.hook_marker"),
(("install", "flag"), "git-dir", "install.flag"),
(("home", "sandbox"), "~/.cache/prek", "home.sandbox"),
(("home", "sandbox"), "~/.cache/prek/sandbox", "home.sandbox"),
(("home", "sandbox"), "/tmp/prek", "home.sandbox"),
(("home", "host"), "~/.cache/prek-host", "home.host"),
(("home", "host"), "~/.local/share/a/b", "home.host"),
(("home", "expands_tilde"), False, "home.expands_tilde"),
(("home", "env"), "prek_home", "home.env"),
],
)
def test_a_registry_that_breaks_the_contract_is_refused(
registry: dict[str, Any], path: tuple[str, str], value: object, message: str
) -> None:
broken = copy.deepcopy(registry)
broken["adapters"]["prek"][path[0]][path[1]] = value
assert any(message in e for e in mod.validate(broken)), mod.validate(broken)
def test_overcommit_uses_its_flag_and_runtime_env(registry: dict[str, Any]) -> None:
adapter = registry["adapters"]["overcommit"]
assert "home" not in adapter
assert adapter["install"] == {
"subcommand": "--install",
"aliases": ["-i"],
"strategy": "hooks-path-env",
"runtime_env": True,
}
rendered = mod.render(registry)
assert (
"overcommit) install_subcommand=--install; install_strategy=hooks-path-env; "
"install_aliases='--install -i'" in rendered[mod.SHIM]
)
assert 'hook_manager_runtime_envs="overcommit:overcommit"' in rendered[mod.DISPATCHER]
@pytest.mark.parametrize(
("aliases", "message"),
[
([], "install.aliases: a non-empty list of flags"),
("-i", "install.aliases: a non-empty list of flags"),
(["install"], "install.aliases: a non-empty list of flags"),
(["--install"], "install.aliases: must not repeat the subcommand"),
],
)
def test_bad_install_aliases_are_refused(registry: dict[str, Any], aliases: object, message: str) -> None:
broken = copy.deepcopy(registry)
broken["adapters"]["overcommit"]["install"]["aliases"] = aliases
assert any(message in e for e in mod.validate(broken)), mod.validate(broken)
def test_captainhook_matches_a_prefix_and_reads_vendor_bin(registry: dict[str, Any]) -> None:
adapter = registry["adapters"]["captainhook"]
assert "home" not in adapter
assert adapter["install"] == {
"subcommand": "install",
"strategy": "hooks-path-env",
"match": "prefix",
"vendor_bin": True,
}
rendered = mod.render(registry)
assert (
"captainhook) install_subcommand=install; install_strategy=hooks-path-env; "
"install_match=prefix; install_vendor_bin=1" in rendered[mod.SHIM]
)
assert "captainhook:CaptainHook" not in rendered[mod.DISPATCHER]
@pytest.mark.parametrize(
("key", "value", "message"),
[
("match", "fuzzy", "install.match"),
("vendor_bin", "yes", "install.vendor_bin"),
],
)
def test_bad_captainhook_install_keys_are_refused(
registry: dict[str, Any], key: str, value: object, message: str
) -> None:
broken = copy.deepcopy(registry)
broken["adapters"]["captainhook"]["install"][key] = value
assert any(message in e for e in mod.validate(broken)), mod.validate(broken)
def test_prefix_matching_needs_a_positional_subcommand(registry: dict[str, Any]) -> None:
broken = copy.deepcopy(registry)
broken["adapters"]["overcommit"]["install"]["match"] = "prefix"
assert any("prefix matching needs a positional subcommand" in e for e in mod.validate(broken)), (
mod.validate(broken)
)
def test_aliases_need_a_flag_style_subcommand(registry: dict[str, Any]) -> None:
broken = copy.deepcopy(registry)
broken["adapters"]["prek"]["install"]["aliases"] = ["-i"]
assert any("only a flag-style subcommand takes aliases" in e for e in mod.validate(broken)), mod.validate(
broken
)
def test_a_manager_without_a_home_is_allowed(registry: dict[str, Any]) -> None:
del registry["adapters"]["prek"]["home"]
assert mod.validate(registry) == []
assert mod.render(registry)[mod.DISPATCHER] == (
'hook_manager_homes=""\nhook_manager_runtime_envs="overcommit:overcommit"\n'
)
def test_strategy_none_adapter_omits_shim_and_needs_no_subcommand(registry: dict[str, Any]) -> None:
assert mod.validate(registry) == []
shim = mod.render(registry)[mod.SHIM]
# Tools with strategy: none must not emit a case in the shim
for tool in ("cargo-husky", "git-build-hook", "git-code-format-maven-plugin", "simple-git-hooks"):
assert f" {tool})" not in shim
# Prek (git-dir-flag) must still emit a case
assert (
" prek) install_subcommand=install; install_strategy=git-dir-flag; install_flag=--git-dir ;;"
in shim
)
# Lefthook needs a rewrite too, but has no flag to inject
assert " lefthook) install_subcommand=install; install_strategy=hide-global-config ;;" in shim
def test_strategy_none_with_flag_is_refused(registry: dict[str, Any]) -> None:
broken = copy.deepcopy(registry)
broken["adapters"]["simple-git-hooks"]["install"]["flag"] = "--git-dir"
errors = mod.validate(broken)
assert any("takes no flag" in e for e in errors), errors
def test_hide_global_config_with_flag_is_refused(registry: dict[str, Any]) -> None:
broken = copy.deepcopy(registry)
broken["adapters"]["lefthook"]["install"]["flag"] = "--git-dir"
errors = mod.validate(broken)
assert any("takes no flag" in e for e in errors), errors
def test_strategy_none_with_aliases_is_refused(registry: dict[str, Any]) -> None:
broken = copy.deepcopy(registry)
broken["adapters"]["simple-git-hooks"]["install"]["aliases"] = ["-i"]
errors = mod.validate(broken)
assert any("strategy 'none' takes no aliases" in e for e in errors), errors
def _add_demo(tree: Path, registry: dict[str, Any]) -> None:
extra = copy.deepcopy(registry["adapters"]["prek"])
extra["detect"] = {"config_files": ["demo.toml"], "nested": False, "hook_marker": "demo"}
extra["home"].update(env="DEMO_HOME", default="~/.cache/demo", sandbox="~/.cache/demo-sandbox")
extra["home"]["host"] = "~/.local/share/demo-host"
registry["adapters"]["demo"] = extra
(tree / mod.REGISTRY).write_text(json.dumps(registry), encoding="utf-8")
def test_a_new_adapter_reaches_every_block(tree: Path, registry: dict[str, Any]) -> None:
_add_demo(tree, registry)
assert mod.main(["--check", "--root", str(tree)]) == 1
assert mod.main(["--root", str(tree)]) == 1 # rewrote the blocks; the settings lack the demo deny
assert " demo) install_subcommand=install;" in (tree / mod.SHIM).read_text()
dispatcher = (tree / mod.DISPATCHER).read_text()
assert 'hook_manager_homes="DEMO_HOME=demo-host PREK_HOME=prek-host"' in dispatcher
assert 'hook_manager_runtime_envs="overcommit:overcommit"' in dispatcher
lint = (tree / mod.SANDBOX_LINT).read_text()
assert (
' "DEMO_HOME",\n "~/.cache/demo",\n "~/.cache/demo-sandbox",\n ("demo.toml",),'
in lint
)
def _set(tree: Path, env: dict[str, str], deny: list[str]) -> None:
for rel in mod.SETTINGS:
settings = json.loads((tree / rel).read_text())
settings["env"].update(env)
settings["permissions"]["deny"].extend(deny)
(tree / rel).write_text(json.dumps(settings), encoding="utf-8")
def test_every_adapter_needs_its_deny_and_a_used_one_its_home(tree: Path, registry: dict[str, Any]) -> None:
"""The default-home deny is per-user; the sandbox home only where the repository uses it."""
_add_demo(tree, registry)
mod.main(["--root", str(tree)])
_set(tree, {}, ["Edit(~/.cache/demo/**)"])
assert mod.main(["--check", "--root", str(tree)]) == 0 # not used here: the deny is enough
(tree / "demo.toml").write_text("")
assert mod.main(["--check", "--root", str(tree)]) == 1
_set(tree, {"DEMO_HOME": "~/.cache/demo-sandbox"}, [])
assert mod.main(["--check", "--root", str(tree)]) == 0
def test_a_repository_without_the_manager_needs_no_settings(tree: Path) -> None:
(tree / ".pre-commit-config.yaml").unlink()
rel = mod.SETTINGS[0]
settings = json.loads((tree / rel).read_text())
del settings["env"]["PREK_HOME"]
(tree / rel).write_text(json.dumps(settings), encoding="utf-8")
assert mod.main(["--check", "--root", str(tree)]) == 0
def test_settings_without_the_sandbox_home_fail(tree: Path) -> None:
rel = mod.SETTINGS[0]
settings = json.loads((tree / rel).read_text())
del settings["env"]["PREK_HOME"]
(tree / rel).write_text(json.dumps(settings), encoding="utf-8")
assert mod.main(["--check", "--root", str(tree)]) == 1
def test_a_missing_block_is_reported(tree: Path) -> None:
shim = tree / mod.SHIM
shim.write_text(shim.read_text().replace("# BEGIN generated", "# BEGIN removed"))
assert mod.main(["--root", str(tree)]) == 1
def test_empty_config_files_allowed_with_hook_marker(registry: dict[str, Any]) -> None:
valid = copy.deepcopy(registry)
valid["adapters"]["cargo-husky"]["detect"]["config_files"] = []
valid["adapters"]["cargo-husky"]["detect"]["hook_marker"] = "cargo-husky"
assert mod.validate(valid) == []
def test_none_strategy_generates_no_shim_case(registry: dict[str, Any]) -> None:
"""Adapters with strategy 'none' get no shim case in hook-manager-shim.sh."""
blocks = mod.render(registry)
shim_body = blocks[mod.SHIM]
for name, adapter in registry["adapters"].items():
if adapter["install"]["strategy"] == "none":
assert f"{name})" not in shim_body