hooks.py
python
sha256:d4df453a03f8b54af479f32103d9023556b28a0e38292215446959f238f3e418
feat(#192): Phase 2 — muse hooks install/uninstall/status (…
Sonnet 5
patch
1 day 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 tomllib |
| 59 | from dataclasses import dataclass, field |
| 60 | |
| 61 | from muse.core.paths import hooks_installed_toml_path |
| 62 | |
| 63 | _FILENAME = ".musehooks.toml" |
| 64 | |
| 65 | # MVP is deliberately scoped to pre-commit only — see musehub#192 "Out of Scope". |
| 66 | VALID_HOOK_POINTS: frozenset[str] = frozenset({"pre-commit"}) |
| 67 | |
| 68 | # 1 MiB cap — prevents OOM from a crafted or corrupted .musehooks.toml, |
| 69 | # matching the precedent set by .museattributes (muse/core/attributes.py). |
| 70 | _MAX_HOOKS_BYTES: int = 1 * 1024 * 1024 |
| 71 | |
| 72 | |
| 73 | @dataclass(frozen=True) |
| 74 | class HooksFile: |
| 75 | """Parsed representation of ``.musehooks.toml``. |
| 76 | |
| 77 | Attributes: |
| 78 | hooks: Mapping of hook point name (e.g. ``"pre-commit"``) to its |
| 79 | ordered list of shell commands. A hook point with no |
| 80 | ``commands`` key, or an empty ``commands = []``, maps to an |
| 81 | empty list — both are valid, meaning "hook point declared but |
| 82 | nothing to run yet." |
| 83 | """ |
| 84 | |
| 85 | hooks: dict[str, list[str]] = field(default_factory=dict) |
| 86 | |
| 87 | |
| 88 | def load_hooks(root) -> HooksFile: # noqa: ANN001 - accepts pathlib.Path |
| 89 | """Parse ``.musehooks.toml`` from *root* and return a :class:`HooksFile`. |
| 90 | |
| 91 | A missing file is not an error — it means no hooks are defined, and |
| 92 | returns an empty :class:`HooksFile`. This matches |
| 93 | :func:`muse.core.attributes.load_attributes`'s precedent: absence is a |
| 94 | valid, common state, not a failure. |
| 95 | |
| 96 | Args: |
| 97 | root: Repository root directory (``pathlib.Path``). |
| 98 | |
| 99 | Returns: |
| 100 | The parsed :class:`HooksFile`. Empty (``hooks={}``) when the file is |
| 101 | absent, empty, or contains only comments. |
| 102 | |
| 103 | Raises: |
| 104 | ValueError: If the file exceeds the 1 MiB size cap, contains invalid |
| 105 | TOML syntax, declares an unknown hook point, or a hook point's |
| 106 | ``commands`` value is not a list of strings. |
| 107 | """ |
| 108 | hooks_file = root / _FILENAME |
| 109 | if not hooks_file.exists(): |
| 110 | return HooksFile(hooks={}) |
| 111 | |
| 112 | raw_bytes = hooks_file.read_bytes() |
| 113 | if len(raw_bytes) > _MAX_HOOKS_BYTES: |
| 114 | raise ValueError( |
| 115 | f"{_FILENAME}: file too large ({len(raw_bytes):,} bytes > " |
| 116 | f"{_MAX_HOOKS_BYTES:,} byte limit)" |
| 117 | ) |
| 118 | |
| 119 | try: |
| 120 | raw = tomllib.loads(raw_bytes.decode("utf-8")) |
| 121 | except tomllib.TOMLDecodeError as exc: |
| 122 | raise ValueError(f"{_FILENAME}: TOML parse error — {exc}") from exc |
| 123 | |
| 124 | hooks: dict[str, list[str]] = {} |
| 125 | for section_name, section_value in raw.items(): |
| 126 | if section_name not in VALID_HOOK_POINTS: |
| 127 | raise ValueError( |
| 128 | f"{_FILENAME}: unknown hook point [{section_name}]. " |
| 129 | f"Valid hook points: {sorted(VALID_HOOK_POINTS)}" |
| 130 | ) |
| 131 | if not isinstance(section_value, dict): |
| 132 | raise ValueError( |
| 133 | f"{_FILENAME}: [{section_name}] must be a table" |
| 134 | ) |
| 135 | |
| 136 | commands_raw = section_value.get("commands", []) |
| 137 | if not isinstance(commands_raw, list): |
| 138 | raise ValueError( |
| 139 | f"{_FILENAME}: [{section_name}].commands must be a list of strings" |
| 140 | ) |
| 141 | commands: list[str] = [] |
| 142 | for idx, cmd in enumerate(commands_raw): |
| 143 | if not isinstance(cmd, str): |
| 144 | raise ValueError( |
| 145 | f"{_FILENAME}: [{section_name}].commands[{idx}] must be " |
| 146 | f"a string, got {type(cmd).__name__}" |
| 147 | ) |
| 148 | commands.append(cmd) |
| 149 | hooks[section_name] = commands |
| 150 | |
| 151 | return HooksFile(hooks=hooks) |
| 152 | |
| 153 | |
| 154 | @dataclass(frozen=True) |
| 155 | class HooksStatus: |
| 156 | """Combined view of declared hooks and this clone's local install state. |
| 157 | |
| 158 | Attributes: |
| 159 | state: One of ``"not_defined"`` (the tracked file is absent, empty, |
| 160 | or contains no ``[<hook-point>]`` sections at all), |
| 161 | ``"defined_not_installed"`` (at least one hook point section is |
| 162 | declared — even with an empty ``commands`` list — but this |
| 163 | clone hasn't run :func:`install_hooks`), or ``"installed"`` |
| 164 | (declared and activated for this clone). ``"not_defined"`` |
| 165 | always wins even if a stale local install marker is present — |
| 166 | there's nothing left to run. |
| 167 | hooks_file: The parsed :class:`HooksFile`. |
| 168 | """ |
| 169 | |
| 170 | state: str |
| 171 | hooks_file: HooksFile |
| 172 | |
| 173 | |
| 174 | def install_hooks(root) -> None: # noqa: ANN001 - accepts pathlib.Path |
| 175 | """Activate declared hooks for *this clone* by writing a local marker. |
| 176 | |
| 177 | Idempotent — calling this when already installed is a no-op, not an |
| 178 | error. Does not require ``.musehooks.toml`` to exist or declare any |
| 179 | commands yet; installing ahead of the file being added is valid; it |
| 180 | simply means nothing runs until commands are declared. |
| 181 | """ |
| 182 | marker = hooks_installed_toml_path(root) |
| 183 | marker.parent.mkdir(parents=True, exist_ok=True) |
| 184 | marker.write_text("installed = true\n", encoding="utf-8") |
| 185 | |
| 186 | |
| 187 | def uninstall_hooks(root) -> None: # noqa: ANN001 - accepts pathlib.Path |
| 188 | """Deactivate hooks for *this clone* by removing the local marker. |
| 189 | |
| 190 | Idempotent — calling this when never installed (or already uninstalled) |
| 191 | is a no-op, not an error. |
| 192 | """ |
| 193 | marker = hooks_installed_toml_path(root) |
| 194 | marker.unlink(missing_ok=True) |
| 195 | |
| 196 | |
| 197 | def is_installed(root) -> bool: # noqa: ANN001 - accepts pathlib.Path |
| 198 | """Return whether this clone has hooks installed (the local marker exists).""" |
| 199 | return hooks_installed_toml_path(root).exists() |
| 200 | |
| 201 | |
| 202 | def get_status(root) -> HooksStatus: # noqa: ANN001 - accepts pathlib.Path |
| 203 | """Return the combined declared-hooks + local-install-state summary. |
| 204 | |
| 205 | Raises: |
| 206 | ValueError: Propagated from :func:`load_hooks` if ``.musehooks.toml`` |
| 207 | is malformed. |
| 208 | """ |
| 209 | hooks_file = load_hooks(root) |
| 210 | |
| 211 | if not hooks_file.hooks: |
| 212 | state = "not_defined" |
| 213 | elif is_installed(root): |
| 214 | state = "installed" |
| 215 | else: |
| 216 | state = "defined_not_installed" |
| 217 | |
| 218 | return HooksStatus(state=state, hooks_file=hooks_file) |
File History
1 commit
sha256:d4df453a03f8b54af479f32103d9023556b28a0e38292215446959f238f3e418
feat(#192): Phase 2 — muse hooks install/uninstall/status (…
Sonnet 5
patch
1 day ago