gabriel / muse public
hooks.py python
218 lines 8.3 KB
Raw
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