merge_hooks.py file-level

at main · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """Generic pre-merge hook framework — Phase 4.4.
2
3 A small extension point that lets domain plugins register *out-of-band*
4 side-effects fired around a ``muse merge`` invocation. The intent is
5 **non-blocking**: hooks may kick off background work (HTTP requests to a
6 local daemon, file-system probes, IPC messages) but the merge engine
7 must never wait on them, and a hook crash must never abort the merge.
8
9 The first consumer is the Knowtation domain (see
10 :mod:`muse.plugins.knowtation.hooks`), which uses this framework to fire
11 ``POST /api/v1/memory/consolidate`` against the local Knowtation hub when
12 the user passes ``muse merge --consolidate``. Per the
13 :doc:`Q3 recommendation <plans/knowtation_domain_in_muse>`,
14 consolidation is *external process today, declarative pre-merge hook
15 later* — the merge UX is never blocked on LLM latency.
16
17 Design contract
18 ---------------
19
20 1. **Out-of-band by default.** Hooks that perform any I/O whose latency
21 is not bounded by the merge engine itself must spawn a background
22 thread (or otherwise dispatch async work) and return immediately with
23 :attr:`MergeHookResult.out_of_band` set to ``True``.
24
25 2. **Errors are non-fatal.** Any exception inside a hook implementation
26 is captured by :meth:`MergeHookRegistry.run_pre_merge` and reported in
27 :attr:`MergeHookResult.error`. The merge caller never sees the
28 exception and never fails because of a hook failure.
29
30 3. **Domains are isolated.** Each registered plugin has its own domain
31 string key. The registry never invokes a hook for a domain that did
32 not register it.
33
34 4. **Thread-safe.** The module-level singleton uses an internal
35 :class:`threading.Lock` to serialise registration so concurrent
36 registrations (e.g. lazy-loaded plugins) cannot lose entries.
37 """
38
39 from __future__ import annotations
40
41 import logging
42 import pathlib
43 import threading
44 from dataclasses import dataclass
45 from typing import Protocol, runtime_checkable
46
47 logger = logging.getLogger(__name__)
48
49
50 # ---------------------------------------------------------------------------
51 # Result type
52 # ---------------------------------------------------------------------------
53
54
55 @dataclass(frozen=True)
56 class MergeHookResult:
57 """Result of a single pre-merge hook invocation.
58
59 Frozen dataclass so callers cannot mutate the result after the hook
60 returns; this guarantees the merge caller and the audit log see the
61 same value the hook produced.
62
63 Attributes:
64 hook_name: Stable identifier for the hook (``"<domain>-<purpose>"``,
65 e.g. ``"knowtation-consolidate"``). Used for logging and for
66 the ``consolidation_hook`` field in JSON CLI output.
67 fired: ``True`` when the hook actually executed any logic.
68 ``False`` when the hook chose to no-op (e.g. an opt-in flag
69 was not set, or a precondition failed without being an error).
70 out_of_band: ``True`` when the hook dispatched its real work to a
71 background thread (or otherwise asynchronously) and the merge
72 engine MUST NOT wait on it. By contract every Knowtation
73 consolidation hook returns ``out_of_band=True``.
74 message: Short human-readable status (≤ 200 chars). Surfaced in
75 ``muse merge`` text output and in the JSON
76 ``consolidation_hook.message`` field.
77 error: ``None`` on success. When non-``None``, the hook
78 encountered an error that was caught by the registry and
79 logged at WARNING level. The merge proceeded anyway; this
80 field exists for diagnostics, not flow-control.
81 """
82
83 hook_name: str
84 fired: bool
85 out_of_band: bool
86 message: str
87 error: str | None
88
89
90 # ---------------------------------------------------------------------------
91 # Plugin protocol
92 # ---------------------------------------------------------------------------
93
94
95 @runtime_checkable
96 class MergeHookPlugin(Protocol):
97 """Structural type for a pre-merge hook provider.
98
99 A plugin is any object exposing :meth:`pre_merge_hook` with the
100 signature below. Domain plugins typically expose this in a
101 sub-module (``muse/plugins/<domain>/hooks.py``) and register a
102 singleton via the domain's ``register_*_hook()`` helper.
103
104 Implementations MUST:
105
106 * Be safe to call from any thread (the merge engine may invoke
107 multiple hooks concurrently in the future, even though today they
108 run serially).
109 * Return a :class:`MergeHookResult` with ``out_of_band=True`` if any
110 I/O is performed asynchronously.
111 * Never raise — any exceptional condition belongs in
112 :attr:`MergeHookResult.error`.
113 """
114
115 def pre_merge_hook(
116 self,
117 root: pathlib.Path,
118 ours_commit_id: str,
119 theirs_commit_id: str,
120 branch: str,
121 *,
122 consolidate: bool = False,
123 ) -> "MergeHookResult":
124 """Invoke the hook for a single merge operation.
125
126 Args:
127 root: Repository root directory (contains ``.muse/``).
128 ours_commit_id: Commit ID of HEAD of the current branch
129 immediately *before* the merge engine writes the merge
130 commit. Hooks MUST treat this as opaque.
131 theirs_commit_id: Commit ID of HEAD of the branch being
132 merged in. Same opacity contract as *ours_commit_id*.
133 branch: Name of the branch being merged in (the "theirs"
134 branch). Already validated by the merge command.
135 consolidate: Domain-specific feature flag — when ``False``
136 the hook should return a no-op result with
137 ``fired=False``. Plumbed through from
138 ``muse merge --consolidate``.
139
140 Returns:
141 A :class:`MergeHookResult` describing what the hook did.
142 Implementations MUST NOT raise; convert exceptions to
143 ``MergeHookResult(error=str(exc), ...)``.
144 """
145 ...
146
147
148 # ---------------------------------------------------------------------------
149 # Registry
150 # ---------------------------------------------------------------------------
151
152
153 class MergeHookRegistry:
154 """Thread-safe registry mapping domain names to hook plugins.
155
156 Instances are usually obtained via :func:`get_hook_registry` so all
157 callers share the same singleton. A fresh ``MergeHookRegistry()``
158 instance is useful in tests where isolation between cases matters
159 more than process-wide visibility.
160 """
161
162 def __init__(self) -> None:
163 """Initialise an empty registry with its own lock."""
164 self._plugins: dict[str, MergeHookPlugin] = {}
165 self._lock: threading.Lock = threading.Lock()
166
167 def register(self, domain: str, plugin: MergeHookPlugin) -> None:
168 """Register *plugin* under *domain*, replacing any prior entry.
169
170 Args:
171 domain: Domain name string (e.g. ``"knowtation"``). Used as
172 the lookup key; case-sensitive.
173 plugin: An object implementing the
174 :class:`MergeHookPlugin` protocol. Not validated
175 structurally — duck-typing is sufficient because
176 :meth:`run_pre_merge` only calls
177 :meth:`MergeHookPlugin.pre_merge_hook`.
178
179 Raises:
180 ValueError: If *domain* is empty or not a string.
181 """
182 if not isinstance(domain, str) or not domain:
183 raise ValueError("domain must be a non-empty string")
184 with self._lock:
185 self._plugins[domain] = plugin
186
187 def get(self, domain: str) -> MergeHookPlugin | None:
188 """Return the registered plugin for *domain* or ``None``.
189
190 Args:
191 domain: Domain name string (e.g. ``"knowtation"``).
192
193 Returns:
194 The :class:`MergeHookPlugin` registered for *domain*, or
195 ``None`` when no plugin has been registered. Never raises.
196 """
197 with self._lock:
198 return self._plugins.get(domain)
199
200 def unregister(self, domain: str) -> bool:
201 """Remove the plugin registered under *domain*, if any.
202
203 Useful in tests that need to restore a clean registry state
204 between cases.
205
206 Args:
207 domain: Domain name string.
208
209 Returns:
210 ``True`` if a plugin was removed, ``False`` when no plugin
211 was registered for *domain*.
212 """
213 with self._lock:
214 return self._plugins.pop(domain, None) is not None
215
216 def run_pre_merge(
217 self,
218 root: pathlib.Path,
219 domain: str,
220 ours_commit_id: str,
221 theirs_commit_id: str,
222 branch: str,
223 *,
224 consolidate: bool = False,
225 ) -> list[MergeHookResult]:
226 """Invoke the registered hook for *domain* and return its result.
227
228 Returns a list (rather than a single result) so future versions
229 can fan out to multiple hooks per domain without breaking the
230 caller signature. Today the list contains zero results (no
231 plugin registered) or one result (the registered plugin's
232 return value).
233
234 The merge engine MUST NOT wait on any work the hook dispatched —
235 results with :attr:`MergeHookResult.out_of_band` set to ``True``
236 carry the audit trail of the *dispatch*, not the eventual
237 completion. Errors raised inside the plugin's
238 :meth:`MergeHookPlugin.pre_merge_hook` are caught, logged at
239 WARNING level, and returned as a synthetic
240 :class:`MergeHookResult` with the exception text in
241 :attr:`MergeHookResult.error` — the merge call site never sees
242 the exception.
243
244 Args:
245 root: Repository root directory.
246 domain: Domain name string used to look up the plugin.
247 ours_commit_id: HEAD of the current branch before merge.
248 theirs_commit_id: HEAD of the branch being merged in.
249 branch: Name of the branch being merged in.
250 consolidate: Domain-specific feature flag plumbed through
251 from ``muse merge --consolidate``.
252
253 Returns:
254 A list of :class:`MergeHookResult` (length 0 or 1 today).
255 Empty when no plugin is registered for *domain*.
256 """
257 plugin = self.get(domain)
258 if plugin is None:
259 return []
260 try:
261 result = plugin.pre_merge_hook(
262 root,
263 ours_commit_id,
264 theirs_commit_id,
265 branch,
266 consolidate=consolidate,
267 )
268 except Exception as exc: # noqa: BLE001 — see docstring contract
269 logger.warning(
270 "merge_hooks: domain=%s plugin=%s raised %s: %s",
271 domain,
272 type(plugin).__name__,
273 type(exc).__name__,
274 exc,
275 )
276 return [
277 MergeHookResult(
278 hook_name=f"{domain}-unknown",
279 fired=False,
280 out_of_band=False,
281 message="hook raised an exception (caught)",
282 error=f"{type(exc).__name__}: {exc}",
283 )
284 ]
285 if not isinstance(result, MergeHookResult):
286 logger.warning(
287 "merge_hooks: domain=%s plugin=%s returned non-MergeHookResult: %r",
288 domain,
289 type(plugin).__name__,
290 type(result).__name__,
291 )
292 return [
293 MergeHookResult(
294 hook_name=f"{domain}-unknown",
295 fired=False,
296 out_of_band=False,
297 message="hook returned wrong type (ignored)",
298 error=f"expected MergeHookResult, got {type(result).__name__}",
299 )
300 ]
301 return [result]
302
303
304 # ---------------------------------------------------------------------------
305 # Singleton accessor
306 # ---------------------------------------------------------------------------
307
308
309 _REGISTRY_LOCK: threading.Lock = threading.Lock()
310 _REGISTRY: MergeHookRegistry | None = None
311
312
313 def get_hook_registry() -> MergeHookRegistry:
314 """Return the process-wide :class:`MergeHookRegistry` singleton.
315
316 The singleton is constructed lazily under a module-level lock the
317 first time this function is called, so concurrent callers in
318 different threads always observe the same instance. Tests that
319 need isolation should construct a fresh
320 :class:`MergeHookRegistry` rather than mutating the singleton.
321
322 Returns:
323 The :class:`MergeHookRegistry` shared by all callers.
324 """
325 global _REGISTRY
326 if _REGISTRY is not None:
327 return _REGISTRY
328 with _REGISTRY_LOCK:
329 if _REGISTRY is None:
330 _REGISTRY = MergeHookRegistry()
331 return _REGISTRY
332
333
334 __all__ = [
335 "MergeHookPlugin",
336 "MergeHookRegistry",
337 "MergeHookResult",
338 "get_hook_registry",
339 ]