hooks.py
python
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