gabriel / muse public
hooks.py python
289 lines 10.7 KB
Raw
sha256:5f7b38765462d4b092afecab903684171b41c9e7aabc0f6515088d43c5e2cb4f feat(#192): Phase 3 — muse commit runs installed pre-commit hooks Sonnet 5 patch 5 days ago
1 """Muse hooks — ``.musehooks.toml`` parser and validator.
2
3 ``.musehooks.toml`` lives in the repository root (next to ``.museignore``,
4 ``.museattributes``, and ``.museagent.md``) and declares commands to run at
5 specific commit-lifecycle points. Unlike ``.muse/hooks/`` (which would live
6 inside the object-store directory and therefore could never be committed,
7 pushed, or survive a clone — ``.muse/`` is unconditionally excluded from
8 every snapshot, see :mod:`muse.core.paths`), ``.musehooks.toml`` is an
9 ordinary tracked file: it is shared automatically via clone/push/pull.
10
11 Hooks declared here are **never executed automatically**. A human or agent
12 must explicitly run ``muse hooks install`` in a given clone to activate them
13 locally (musehub#192 Phase 2) — this mirrors git's own precedent of never
14 auto-running code from a freshly cloned repository, and keeps a malicious or
15 compromised repo from gaining code execution merely by being cloned.
16
17 MVP scope (musehub#192): exactly one hook point, ``pre-commit``, and a
18 declarative list of shell commands — no arbitrary hook scripts, no hashbang
19 files. Each command's exit code determines pass/fail when ``muse commit``
20 runs installed hooks (Phase 3).
21
22 Format
23 ------
24
25 .. code-block:: toml
26
27 # .musehooks.toml
28 [pre-commit]
29 commands = [
30 "muse agent-config status --fail-if-out-of-sync",
31 ]
32
33 Local activation (musehub#192 Phase 2)
34 ---------------------------------------
35
36 Whether *this specific clone* runs the declared hooks is tracked in
37 ``.muse/hooks-installed.toml`` — deliberately local-only, unlike
38 ``.musehooks.toml`` itself. This is the one place ``.muse/`` is the
39 *correct* home for state: installed-or-not must never propagate via
40 clone/push/pull, or a cloned repo would gain silent code execution the
41 moment someone ran ``muse commit`` — the same class of risk git avoids by
42 never auto-installing hooks from a fresh clone.
43
44 Public API
45 ----------
46
47 - :class:`HooksFile` — parsed representation of ``.musehooks.toml``.
48 - :class:`HooksStatus` — combined view of declared hooks + local install state.
49 - :data:`VALID_HOOK_POINTS` — the set of recognised ``[<hook-point>]`` section names.
50 - :func:`load_hooks` — read ``.musehooks.toml`` from a repo root.
51 - :func:`install_hooks` — activate declared hooks for this clone.
52 - :func:`uninstall_hooks` — deactivate hooks for this clone.
53 - :func:`is_installed` — whether this clone has hooks installed.
54 - :func:`get_status` — the three-state summary: ``not_defined``,
55 ``defined_not_installed``, or ``installed``.
56 """
57
58 import subprocess
59 import tomllib
60 from dataclasses import dataclass, field
61
62 from muse.core.paths import hooks_installed_toml_path
63
64 _FILENAME = ".musehooks.toml"
65
66 # MVP is deliberately scoped to pre-commit only — see musehub#192 "Out of Scope".
67 VALID_HOOK_POINTS: frozenset[str] = frozenset({"pre-commit"})
68
69 # 1 MiB cap — prevents OOM from a crafted or corrupted .musehooks.toml,
70 # matching the precedent set by .museattributes (muse/core/attributes.py).
71 _MAX_HOOKS_BYTES: int = 1 * 1024 * 1024
72
73
74 @dataclass(frozen=True)
75 class HooksFile:
76 """Parsed representation of ``.musehooks.toml``.
77
78 Attributes:
79 hooks: Mapping of hook point name (e.g. ``"pre-commit"``) to its
80 ordered list of shell commands. A hook point with no
81 ``commands`` key, or an empty ``commands = []``, maps to an
82 empty list — both are valid, meaning "hook point declared but
83 nothing to run yet."
84 """
85
86 hooks: dict[str, list[str]] = field(default_factory=dict)
87
88
89 def load_hooks(root) -> HooksFile: # noqa: ANN001 - accepts pathlib.Path
90 """Parse ``.musehooks.toml`` from *root* and return a :class:`HooksFile`.
91
92 A missing file is not an error — it means no hooks are defined, and
93 returns an empty :class:`HooksFile`. This matches
94 :func:`muse.core.attributes.load_attributes`'s precedent: absence is a
95 valid, common state, not a failure.
96
97 Args:
98 root: Repository root directory (``pathlib.Path``).
99
100 Returns:
101 The parsed :class:`HooksFile`. Empty (``hooks={}``) when the file is
102 absent, empty, or contains only comments.
103
104 Raises:
105 ValueError: If the file exceeds the 1 MiB size cap, contains invalid
106 TOML syntax, declares an unknown hook point, or a hook point's
107 ``commands`` value is not a list of strings.
108 """
109 hooks_file = root / _FILENAME
110 if not hooks_file.exists():
111 return HooksFile(hooks={})
112
113 raw_bytes = hooks_file.read_bytes()
114 if len(raw_bytes) > _MAX_HOOKS_BYTES:
115 raise ValueError(
116 f"{_FILENAME}: file too large ({len(raw_bytes):,} bytes > "
117 f"{_MAX_HOOKS_BYTES:,} byte limit)"
118 )
119
120 try:
121 raw = tomllib.loads(raw_bytes.decode("utf-8"))
122 except tomllib.TOMLDecodeError as exc:
123 raise ValueError(f"{_FILENAME}: TOML parse error — {exc}") from exc
124
125 hooks: dict[str, list[str]] = {}
126 for section_name, section_value in raw.items():
127 if section_name not in VALID_HOOK_POINTS:
128 raise ValueError(
129 f"{_FILENAME}: unknown hook point [{section_name}]. "
130 f"Valid hook points: {sorted(VALID_HOOK_POINTS)}"
131 )
132 if not isinstance(section_value, dict):
133 raise ValueError(
134 f"{_FILENAME}: [{section_name}] must be a table"
135 )
136
137 commands_raw = section_value.get("commands", [])
138 if not isinstance(commands_raw, list):
139 raise ValueError(
140 f"{_FILENAME}: [{section_name}].commands must be a list of strings"
141 )
142 commands: list[str] = []
143 for idx, cmd in enumerate(commands_raw):
144 if not isinstance(cmd, str):
145 raise ValueError(
146 f"{_FILENAME}: [{section_name}].commands[{idx}] must be "
147 f"a string, got {type(cmd).__name__}"
148 )
149 commands.append(cmd)
150 hooks[section_name] = commands
151
152 return HooksFile(hooks=hooks)
153
154
155 @dataclass(frozen=True)
156 class HooksStatus:
157 """Combined view of declared hooks and this clone's local install state.
158
159 Attributes:
160 state: One of ``"not_defined"`` (the tracked file is absent, empty,
161 or contains no ``[<hook-point>]`` sections at all),
162 ``"defined_not_installed"`` (at least one hook point section is
163 declared — even with an empty ``commands`` list — but this
164 clone hasn't run :func:`install_hooks`), or ``"installed"``
165 (declared and activated for this clone). ``"not_defined"``
166 always wins even if a stale local install marker is present —
167 there's nothing left to run.
168 hooks_file: The parsed :class:`HooksFile`.
169 """
170
171 state: str
172 hooks_file: HooksFile
173
174
175 def install_hooks(root) -> None: # noqa: ANN001 - accepts pathlib.Path
176 """Activate declared hooks for *this clone* by writing a local marker.
177
178 Idempotent — calling this when already installed is a no-op, not an
179 error. Does not require ``.musehooks.toml`` to exist or declare any
180 commands yet; installing ahead of the file being added is valid; it
181 simply means nothing runs until commands are declared.
182 """
183 marker = hooks_installed_toml_path(root)
184 marker.parent.mkdir(parents=True, exist_ok=True)
185 marker.write_text("installed = true\n", encoding="utf-8")
186
187
188 def uninstall_hooks(root) -> None: # noqa: ANN001 - accepts pathlib.Path
189 """Deactivate hooks for *this clone* by removing the local marker.
190
191 Idempotent — calling this when never installed (or already uninstalled)
192 is a no-op, not an error.
193 """
194 marker = hooks_installed_toml_path(root)
195 marker.unlink(missing_ok=True)
196
197
198 def is_installed(root) -> bool: # noqa: ANN001 - accepts pathlib.Path
199 """Return whether this clone has hooks installed (the local marker exists)."""
200 return hooks_installed_toml_path(root).exists()
201
202
203 def get_status(root) -> HooksStatus: # noqa: ANN001 - accepts pathlib.Path
204 """Return the combined declared-hooks + local-install-state summary.
205
206 Raises:
207 ValueError: Propagated from :func:`load_hooks` if ``.musehooks.toml``
208 is malformed.
209 """
210 hooks_file = load_hooks(root)
211
212 if not hooks_file.hooks:
213 state = "not_defined"
214 elif is_installed(root):
215 state = "installed"
216 else:
217 state = "defined_not_installed"
218
219 return HooksStatus(state=state, hooks_file=hooks_file)
220
221
222 @dataclass(frozen=True)
223 class HookRunResult:
224 """Outcome of running one hook point's commands for this clone.
225
226 Attributes:
227 executed: ``False`` when hooks aren't installed for this clone, or
228 no commands are declared for the given hook point — nothing was
229 run. ``True`` when at least the first command was attempted.
230 passed: ``True`` when every executed command exited 0 (or nothing
231 was executed at all). ``False`` on the first non-zero exit.
232 failed_command: The exact command string that failed, or ``None``
233 when ``passed`` is ``True``.
234 output: Combined stdout+stderr of the failed command. Empty when
235 ``passed`` is ``True``.
236 """
237
238 executed: bool
239 passed: bool
240 failed_command: str | None = None
241 output: str = ""
242
243
244 def run_hook_point(root, hook_point: str) -> HookRunResult: # noqa: ANN001
245 """Run *hook_point*'s declared commands for this clone, in order.
246
247 A no-op (``executed=False, passed=True``) when this clone doesn't have
248 hooks installed, or when no commands are declared for *hook_point* —
249 callers never need to check :func:`is_installed` separately first.
250
251 Commands run via the shell (``cwd=root``), stopping at the first
252 non-zero exit. Subsequent commands never run once one fails, matching
253 the fail-fast expectation of a pre-commit gate.
254
255 Args:
256 root: Repository root directory.
257 hook_point: One of :data:`VALID_HOOK_POINTS`.
258
259 Returns:
260 A :class:`HookRunResult` describing what happened.
261
262 Raises:
263 ValueError: Propagated from :func:`load_hooks` if ``.musehooks.toml``
264 is malformed.
265 """
266 if not is_installed(root):
267 return HookRunResult(executed=False, passed=True)
268
269 hooks_file = load_hooks(root)
270 commands = hooks_file.hooks.get(hook_point, [])
271
272 for cmd in commands:
273 proc = subprocess.run(
274 cmd,
275 shell=True,
276 cwd=root,
277 capture_output=True,
278 text=True,
279 )
280 if proc.returncode != 0:
281 combined = (proc.stdout or "") + (proc.stderr or "")
282 return HookRunResult(
283 executed=True,
284 passed=False,
285 failed_command=cmd,
286 output=combined,
287 )
288
289 return HookRunResult(executed=bool(commands), passed=True)
File History 1 commit
sha256:5f7b38765462d4b092afecab903684171b41c9e7aabc0f6515088d43c5e2cb4f feat(#192): Phase 3 — muse commit runs installed pre-commit hooks Sonnet 5 patch 5 days ago