gabriel / muse public
test_cmd_commit_hooks.py python
170 lines 6.3 KB
Raw
sha256:5f7b38765462d4b092afecab903684171b41c9e7aabc0f6515088d43c5e2cb4f feat(#192): Phase 3 — muse commit runs installed pre-commit hooks Sonnet 5 patch 6 days ago
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.
File History 1 commit
sha256:5f7b38765462d4b092afecab903684171b41c9e7aabc0f6515088d43c5e2cb4f feat(#192): Phase 3 — muse commit runs installed pre-commit hooks Sonnet 5 patch 6 days ago