"""Tests for `muse commit` pre-commit hook integration — musehub#192 Phase 3. HK_20-23 + HK_ACCEPT_01. Uses real subprocess-executed hook commands against real disposable repos (CliRunner + tmp_path), matching this workspace's existing no-mocks-for-real-behavior testing convention. """ from __future__ import annotations import json import os import pathlib from tests.cli_test_helper import CliRunner, InvokeResult runner = CliRunner() def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) def _init_repo(repo: pathlib.Path) -> None: repo.mkdir(parents=True, exist_ok=True) r = _invoke(repo, ["init"]) assert r.exit_code == 0, r.output def _write_hooks(repo: pathlib.Path, content: str) -> None: (repo / ".musehooks.toml").write_text(content, encoding="utf-8") def _stage_a_file(repo: pathlib.Path, name: str = "a.py", content: str = "x = 1\n") -> None: (repo / name).write_text(content) r = _invoke(repo, ["code", "add", name]) assert r.exit_code == 0, r.output class TestCommitRunsInstalledHooks: def test_passing_hook_allows_commit(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 0"]\n') _invoke(tmp_path, ["hooks", "install"]) _stage_a_file(tmp_path) r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) assert r.exit_code == 0, r.output def test_defined_not_installed_does_not_block_commit(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') # Deliberately NOT installed. _stage_a_file(tmp_path) r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) assert r.exit_code == 0, r.output class TestCommitBlockedByFailingHook: def test_failing_hook_blocks_commit_exit_1(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') _invoke(tmp_path, ["hooks", "install"]) _stage_a_file(tmp_path) r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) assert r.exit_code == 1 def test_failing_hook_names_the_command_in_output(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') _invoke(tmp_path, ["hooks", "install"]) _stage_a_file(tmp_path) r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) data = json.loads(r.output) assert data["failed_command"] == "exit 1" def test_failing_hook_prevents_any_object_being_written(self, tmp_path: pathlib.Path) -> None: from muse.core.refs import get_head_commit_id, read_current_branch _init_repo(tmp_path) _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') _invoke(tmp_path, ["hooks", "install"]) _stage_a_file(tmp_path) branch = read_current_branch(tmp_path) before = get_head_commit_id(tmp_path, branch) _invoke(tmp_path, ["commit", "-m", "test", "--json"]) after = get_head_commit_id(tmp_path, branch) assert before == after class TestNoVerifyBypass: def test_no_verify_allows_commit_despite_failing_hook(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') _invoke(tmp_path, ["hooks", "install"]) _stage_a_file(tmp_path) r = _invoke(tmp_path, ["commit", "-m", "test", "--no-verify", "--json"]) assert r.exit_code == 0, r.output def test_no_verify_is_never_silent_json_notes_skip(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') _invoke(tmp_path, ["hooks", "install"]) _stage_a_file(tmp_path) r = _invoke(tmp_path, ["commit", "-m", "test", "--no-verify", "--json"]) data = json.loads(r.output) assert any("no-verify" in w.lower() or "skip" in w.lower() for w in data["warnings"]) def test_no_verify_with_no_hooks_installed_is_harmless(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _stage_a_file(tmp_path) r = _invoke(tmp_path, ["commit", "-m", "test", "--no-verify", "--json"]) assert r.exit_code == 0, r.output class TestDefinedNotInstalledWarning: def test_json_warns_when_hooks_defined_but_not_installed(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 0"]\n') # Not installed. _stage_a_file(tmp_path) r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) data = json.loads(r.output) assert any("not installed" in w.lower() for w in data["warnings"]) def test_no_warning_when_hooks_installed(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 0"]\n') _invoke(tmp_path, ["hooks", "install"]) _stage_a_file(tmp_path) r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) data = json.loads(r.output) assert data["warnings"] == [] def test_no_warning_when_no_hooks_defined_at_all(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _stage_a_file(tmp_path) r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) data = json.loads(r.output) assert data["warnings"] == [] class TestDryRunNeverExecutesHooks(object): def test_dry_run_does_not_execute_hook_commands(self, tmp_path: pathlib.Path) -> None: _init_repo(tmp_path) _write_hooks(tmp_path, '[pre-commit]\ncommands = ["touch should-not-exist.txt"]\n') _invoke(tmp_path, ["hooks", "install"]) _stage_a_file(tmp_path) _invoke(tmp_path, ["commit", "-m", "test", "--dry-run", "--json"]) assert not (tmp_path / "should-not-exist.txt").exists() # HK_ACCEPT_01 (the full .museagent.md drift reproduction, using # `muse agent-config status --fail-if-out-of-sync`) lands in Phase 5, once # that flag exists — see tests/test_cmd_commit_hooks_acceptance.py.