test_cmd_commit_hooks.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """Tests for `muse commit` pre-commit hook integration — musehub#192 Phase 3. |
| 2 | |
| 3 | HK_20-23 + HK_ACCEPT_01. Uses real subprocess-executed hook commands against |
| 4 | real disposable repos (CliRunner + tmp_path), matching this workspace's |
| 5 | existing no-mocks-for-real-behavior testing convention. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import json |
| 11 | import os |
| 12 | import pathlib |
| 13 | |
| 14 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 15 | |
| 16 | runner = CliRunner() |
| 17 | |
| 18 | |
| 19 | def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: |
| 20 | saved = os.getcwd() |
| 21 | try: |
| 22 | os.chdir(repo) |
| 23 | return runner.invoke(None, args) |
| 24 | finally: |
| 25 | os.chdir(saved) |
| 26 | |
| 27 | |
| 28 | def _init_repo(repo: pathlib.Path) -> None: |
| 29 | repo.mkdir(parents=True, exist_ok=True) |
| 30 | r = _invoke(repo, ["init"]) |
| 31 | assert r.exit_code == 0, r.output |
| 32 | |
| 33 | |
| 34 | def _write_hooks(repo: pathlib.Path, content: str) -> None: |
| 35 | (repo / ".musehooks.toml").write_text(content, encoding="utf-8") |
| 36 | |
| 37 | |
| 38 | def _stage_a_file(repo: pathlib.Path, name: str = "a.py", content: str = "x = 1\n") -> None: |
| 39 | (repo / name).write_text(content) |
| 40 | r = _invoke(repo, ["code", "add", name]) |
| 41 | assert r.exit_code == 0, r.output |
| 42 | |
| 43 | |
| 44 | class TestCommitRunsInstalledHooks: |
| 45 | def test_passing_hook_allows_commit(self, tmp_path: pathlib.Path) -> None: |
| 46 | _init_repo(tmp_path) |
| 47 | _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 0"]\n') |
| 48 | _invoke(tmp_path, ["hooks", "install"]) |
| 49 | _stage_a_file(tmp_path) |
| 50 | |
| 51 | r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) |
| 52 | assert r.exit_code == 0, r.output |
| 53 | |
| 54 | def test_defined_not_installed_does_not_block_commit(self, tmp_path: pathlib.Path) -> None: |
| 55 | _init_repo(tmp_path) |
| 56 | _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') |
| 57 | # Deliberately NOT installed. |
| 58 | _stage_a_file(tmp_path) |
| 59 | |
| 60 | r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) |
| 61 | assert r.exit_code == 0, r.output |
| 62 | |
| 63 | |
| 64 | class TestCommitBlockedByFailingHook: |
| 65 | def test_failing_hook_blocks_commit_exit_1(self, tmp_path: pathlib.Path) -> None: |
| 66 | _init_repo(tmp_path) |
| 67 | _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') |
| 68 | _invoke(tmp_path, ["hooks", "install"]) |
| 69 | _stage_a_file(tmp_path) |
| 70 | |
| 71 | r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) |
| 72 | assert r.exit_code == 1 |
| 73 | |
| 74 | def test_failing_hook_names_the_command_in_output(self, tmp_path: pathlib.Path) -> None: |
| 75 | _init_repo(tmp_path) |
| 76 | _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') |
| 77 | _invoke(tmp_path, ["hooks", "install"]) |
| 78 | _stage_a_file(tmp_path) |
| 79 | |
| 80 | r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) |
| 81 | data = json.loads(r.output) |
| 82 | assert data["failed_command"] == "exit 1" |
| 83 | |
| 84 | def test_failing_hook_prevents_any_object_being_written(self, tmp_path: pathlib.Path) -> None: |
| 85 | from muse.core.refs import get_head_commit_id, read_current_branch |
| 86 | |
| 87 | _init_repo(tmp_path) |
| 88 | _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') |
| 89 | _invoke(tmp_path, ["hooks", "install"]) |
| 90 | _stage_a_file(tmp_path) |
| 91 | |
| 92 | branch = read_current_branch(tmp_path) |
| 93 | before = get_head_commit_id(tmp_path, branch) |
| 94 | |
| 95 | _invoke(tmp_path, ["commit", "-m", "test", "--json"]) |
| 96 | |
| 97 | after = get_head_commit_id(tmp_path, branch) |
| 98 | assert before == after |
| 99 | |
| 100 | |
| 101 | class TestNoVerifyBypass: |
| 102 | def test_no_verify_allows_commit_despite_failing_hook(self, tmp_path: pathlib.Path) -> None: |
| 103 | _init_repo(tmp_path) |
| 104 | _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') |
| 105 | _invoke(tmp_path, ["hooks", "install"]) |
| 106 | _stage_a_file(tmp_path) |
| 107 | |
| 108 | r = _invoke(tmp_path, ["commit", "-m", "test", "--no-verify", "--json"]) |
| 109 | assert r.exit_code == 0, r.output |
| 110 | |
| 111 | def test_no_verify_is_never_silent_json_notes_skip(self, tmp_path: pathlib.Path) -> None: |
| 112 | _init_repo(tmp_path) |
| 113 | _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 1"]\n') |
| 114 | _invoke(tmp_path, ["hooks", "install"]) |
| 115 | _stage_a_file(tmp_path) |
| 116 | |
| 117 | r = _invoke(tmp_path, ["commit", "-m", "test", "--no-verify", "--json"]) |
| 118 | data = json.loads(r.output) |
| 119 | assert any("no-verify" in w.lower() or "skip" in w.lower() for w in data["warnings"]) |
| 120 | |
| 121 | def test_no_verify_with_no_hooks_installed_is_harmless(self, tmp_path: pathlib.Path) -> None: |
| 122 | _init_repo(tmp_path) |
| 123 | _stage_a_file(tmp_path) |
| 124 | r = _invoke(tmp_path, ["commit", "-m", "test", "--no-verify", "--json"]) |
| 125 | assert r.exit_code == 0, r.output |
| 126 | |
| 127 | |
| 128 | class TestDefinedNotInstalledWarning: |
| 129 | def test_json_warns_when_hooks_defined_but_not_installed(self, tmp_path: pathlib.Path) -> None: |
| 130 | _init_repo(tmp_path) |
| 131 | _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 0"]\n') |
| 132 | # Not installed. |
| 133 | _stage_a_file(tmp_path) |
| 134 | |
| 135 | r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) |
| 136 | data = json.loads(r.output) |
| 137 | assert any("not installed" in w.lower() for w in data["warnings"]) |
| 138 | |
| 139 | def test_no_warning_when_hooks_installed(self, tmp_path: pathlib.Path) -> None: |
| 140 | _init_repo(tmp_path) |
| 141 | _write_hooks(tmp_path, '[pre-commit]\ncommands = ["exit 0"]\n') |
| 142 | _invoke(tmp_path, ["hooks", "install"]) |
| 143 | _stage_a_file(tmp_path) |
| 144 | |
| 145 | r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) |
| 146 | data = json.loads(r.output) |
| 147 | assert data["warnings"] == [] |
| 148 | |
| 149 | def test_no_warning_when_no_hooks_defined_at_all(self, tmp_path: pathlib.Path) -> None: |
| 150 | _init_repo(tmp_path) |
| 151 | _stage_a_file(tmp_path) |
| 152 | r = _invoke(tmp_path, ["commit", "-m", "test", "--json"]) |
| 153 | data = json.loads(r.output) |
| 154 | assert data["warnings"] == [] |
| 155 | |
| 156 | |
| 157 | class TestDryRunNeverExecutesHooks(object): |
| 158 | def test_dry_run_does_not_execute_hook_commands(self, tmp_path: pathlib.Path) -> None: |
| 159 | _init_repo(tmp_path) |
| 160 | _write_hooks(tmp_path, '[pre-commit]\ncommands = ["touch should-not-exist.txt"]\n') |
| 161 | _invoke(tmp_path, ["hooks", "install"]) |
| 162 | _stage_a_file(tmp_path) |
| 163 | |
| 164 | _invoke(tmp_path, ["commit", "-m", "test", "--dry-run", "--json"]) |
| 165 | assert not (tmp_path / "should-not-exist.txt").exists() |
| 166 | |
| 167 | |
| 168 | # HK_ACCEPT_01 (the full .museagent.md drift reproduction, using |
| 169 | # `muse agent-config status --fail-if-out-of-sync`) lands in Phase 5, once |
| 170 | # that flag exists — see tests/test_cmd_commit_hooks_acceptance.py. |