store.py file-level

at sha256:c · 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 """File-based commit and snapshot store for the Muse VCS.
2
3 All commit and snapshot metadata is stored as **msgpack** files under
4 ``.muse/`` β€” no external database required.
5
6 msgpack is used instead of JSON because it is 3–6Γ— faster to
7 serialize/deserialize on real repository data and is already a first-class
8 dependency (also used by the MWP wire protocol and ``SymbolCache``).
9 All user-facing output (``muse log --json``, ``muse show``, etc.) is still
10 JSON built from the deserialized in-memory structures.
11
12 Writes are **durable and atomic**: data is flushed with ``fsync`` to a unique
13 ``mkstemp`` temp file then renamed over the target file, so a crash or power
14 loss mid-write never corrupts store data.
15
16 Layout
17 ------
18
19 .muse/
20 commits/<commit_id>.msgpack β€” one msgpack file per commit
21 snapshots/<snapshot_id>.msgpack β€” one msgpack file per snapshot manifest
22 tags/<repo_id>/<tag_id>.msgpack β€” tag records
23 releases/<repo_id>/<release_id>.msgpack β€” release records
24 objects/<sha2>/<sha62> β€” content-addressed blobs (via object_store.py)
25 refs/heads/<branch> β€” branch HEAD pointers (plain text commit IDs)
26 HEAD β€” "ref: refs/heads/main" | "commit: <sha256>"
27 repo.json β€” repository identity (human-editable, JSON)
28
29 Commit schema (in-memory dict shape, serialized as msgpack)
30 -----------------------------------------------------------
31
32 {
33 "commit_id": "<sha256>",
34 "repo_id": "<uuid>",
35 "branch": "main",
36 "parent_commit_id": null | "<sha256>",
37 "parent2_commit_id": null | "<sha256>",
38 "snapshot_id": "<sha256>",
39 "message": "Add verse melody",
40 "author": "gabriel",
41 "committed_at": "2026-03-16T12:00:00+00:00",
42 "metadata": {}
43 }
44
45 Snapshot schema (in-memory dict shape, serialized as msgpack)
46 -------------------------------------------------------------
47
48 {
49 "snapshot_id": "<sha256>",
50 "manifest": {"tracks/drums.mid": "<sha256>", ...},
51 "created_at": "2026-03-16T12:00:00+00:00"
52 }
53
54 All functions are synchronous β€” file I/O on a local ``.muse/`` directory
55 does not require async. This removes the SQLAlchemy/asyncpg dependency from
56 the CLI entirely.
57 """
58
59 from __future__ import annotations
60
61 import datetime
62 import errno
63 import fcntl
64 import logging
65 import os
66 import pathlib
67 import re
68 import sys
69 import tempfile
70 import uuid
71
72 import msgpack
73 from dataclasses import dataclass, field
74
75 from typing import Literal, TypedDict, TypeGuard
76
77 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
78 from muse.core.validation import (
79 assert_not_symlink,
80 sanitize_glob_prefix,
81 validate_branch_name,
82 validate_ref_id,
83 validate_repo_id,
84 )
85 from muse.core.semver import (
86 ApiChangeSummary,
87 ChangelogEntry,
88 FileHotspot,
89 LanguageStat,
90 ReleaseChannel,
91 RefactorEventSummary,
92 SemanticReleaseReport,
93 SemVerTag,
94 SymbolKindCount,
95 _CHANNEL_MAP,
96 _ChannelMap,
97 parse_semver,
98 semver_channel,
99 semver_to_str,
100 )
101 from muse.domain import SemVerBump, StructuredDelta
102
103 logger = logging.getLogger(__name__)
104
105
106 # ---------------------------------------------------------------------------
107 # Read safety constants
108 # ---------------------------------------------------------------------------
109
110 # Maximum size of a msgpack file that _read_msgpack will load into memory.
111 # Commits, snapshots, tags, and releases should never approach this limit in
112 # any realistic repository. It exists to prevent a malicious or corrupted
113 # store file from triggering an OOM before read_bytes() is even called.
114 # Callers (read_commit, read_snapshot, etc.) catch OSError and return None.
115 MAX_MSGPACK_BYTES: int = 64 * 1024 * 1024 # 64 MiB
116
117 # Upper bound for pack/bundle deserialization. Pack files legitimately
118 # contain many binary blobs (raw file content), so they need a larger cap.
119 # Matches MAX_OBJECT_WRITE_BYTES * 2 β€” a generous but bounded envelope.
120 MAX_PACK_MSGPACK_BYTES: int = 512 * 1024 * 1024 # 512 MiB
121
122 # Per-value limits passed to msgpack.unpackb. Even if a file is within the
123 # size cap, a pathological document with a single enormous string, an
124 # astronomically deep nesting, or a billion-entry map can still exhaust RAM.
125 # These limits are deliberately generous for real workloads (snapshot manifests
126 # with 75k+ files, long commit messages) while rejecting absurd inputs.
127 _MSGPACK_MAX_STR_LEN: int = 1_048_576 # 1 MiB per string value
128 _MSGPACK_MAX_BIN_LEN: int = 0 # no binary blobs in commit/snapshot/tag records
129 _MSGPACK_MAX_BIN_LEN_PACK: int = 256 * 1024 * 1024 # 256 MiB per blob in pack/bundle files
130 _MSGPACK_MAX_ARRAY_LEN: int = 1_000_000 # generous for large manifests
131 _MSGPACK_MAX_MAP_LEN: int = 1_000_000 # generous for 75k-file snapshot manifests
132
133
134 # ---------------------------------------------------------------------------
135 # Serialization boundary types
136 # ---------------------------------------------------------------------------
137
138 # ``MsgpackValue`` is the recursive union of every value type that
139 # ``msgpack.unpackb(raw=False)`` can produce. Using this type (instead of
140 # ``object`` or ``Any``) keeps deserialization boundaries fully typed while
141 # preserving the fact that the exact shape of a persisted record is unknown
142 # until the caller narrows it via ``isinstance``.
143 #
144 # Mirrors the Rust ``Value`` enum that will own this boundary after the port:
145 # enum Value { Nil, Bool(bool), Int(i64), Float(f64), Bytes(Vec<u8>),
146 # Str(String), Array(Vec<Value>), Map(HashMap<String,Value>) }
147 # Type aliases are defined in _types.py to avoid circular imports.
148 # Re-exported here so callers can import from muse.core.store as before.
149 from muse.core._types import ( # noqa: E402
150 BranchHeads,
151 JsonValue,
152 Manifest,
153 MsgpackDict,
154 MsgpackValue,
155 Metadata,
156 )
157
158
159 # ---------------------------------------------------------------------------
160 # Typed accessor helpers for deserialized msgpack dicts
161 # ---------------------------------------------------------------------------
162
163
164 def _str_val(
165 d: MsgpackDict, key: str, default: str = ""
166 ) -> str:
167 """Return ``d[key]`` as ``str``, or *default* if absent or wrong type.
168
169 Never raises β€” callers that need strict validation should check the return
170 value or use the runtime guards inside ``from_dict``.
171 """
172 val = d.get(key, default)
173 return val if isinstance(val, str) else default
174
175
176 def _int_val(
177 d: MsgpackDict, key: str, default: int = 0
178 ) -> int:
179 """Return ``d[key]`` as ``int``, or *default* if absent or wrong type.
180
181 Excludes ``bool`` (which is a subclass of ``int`` in Python) to prevent
182 accidental coercion.
183 """
184 val = d.get(key, default)
185 return val if isinstance(val, int) and not isinstance(val, bool) else default
186
187
188 def _str_or_none(
189 d: MsgpackDict, key: str
190 ) -> str | None:
191 """Return ``d[key]`` as ``str | None``.
192
193 Returns ``None`` if the key is absent or the value is not a ``str``.
194 """
195 val = d.get(key)
196 return val if isinstance(val, str) else None
197
198
199 def _str_list(
200 d: MsgpackDict, key: str
201 ) -> list[str]:
202 """Return ``d[key]`` as ``list[str]``, filtering non-string elements."""
203 val = d.get(key)
204 if not isinstance(val, list):
205 return []
206 return [x for x in val if isinstance(x, str)]
207
208
209 def _str_dict(
210 d: MsgpackDict, key: str
211 ) -> Manifest:
212 """Return ``d[key]`` as ``dict[str, str]``, filtering non-string values."""
213 val = d.get(key)
214 if not isinstance(val, dict):
215 return {}
216 return {k: v for k, v in val.items() if isinstance(v, str)}
217
218
219 def _as_structured_delta(
220 v: MsgpackDict,
221 ) -> TypeGuard[StructuredDelta]:
222 """Narrow a raw msgpack dict to :class:`StructuredDelta`.
223
224 ``StructuredDelta`` is ``TypedDict(total=False)`` β€” every field is
225 optional β€” so any ``dict`` is structurally compatible. This TypeGuard
226 exists solely to satisfy the type checker without ``# type: ignore``.
227 """
228 return True
229
230
231 def _sem_ver_bump_val(d: MsgpackDict) -> "SemVerBump":
232 """Extract and validate a ``sem_ver_bump`` field from a raw msgpack dict.
233
234 Falls back to ``"none"`` if the value is absent or not a recognised
235 Literal β€” guards against tampered or forward-versioned records.
236 """
237 val = _str_val(d, "sem_ver_bump", "none")
238 if val == "major":
239 return "major"
240 if val == "minor":
241 return "minor"
242 if val == "patch":
243 return "patch"
244 return "none"
245
246
247 def _parse_semver_tag(
248 d: MsgpackDict, key: str
249 ) -> "SemVerTag":
250 """Extract a nested :class:`SemVerTag` from a raw msgpack mapping.
251
252 Returns a zeroed ``SemVerTag`` if the key is absent or the value is not
253 a dict β€” callers can inspect ``major == 0`` to detect the fallback.
254 """
255 val = d.get(key)
256 if isinstance(val, dict):
257 return SemVerTag(
258 major=_int_val(val, "major"),
259 minor=_int_val(val, "minor"),
260 patch=_int_val(val, "patch"),
261 pre=_str_val(val, "pre"),
262 build=_str_val(val, "build"),
263 )
264 return SemVerTag(major=0, minor=0, patch=0, pre="", build="")
265
266
267 def _parse_changelog_entries(
268 d: MsgpackDict, key: str
269 ) -> "list[ChangelogEntry]":
270 """Extract a ``list[ChangelogEntry]`` from a raw msgpack mapping.
271
272 Silently skips items that are not dicts β€” defensive deserialization
273 at the boundary between wire format and in-memory types.
274 """
275 val = d.get(key)
276 if not isinstance(val, list):
277 return []
278 entries: list[ChangelogEntry] = []
279 for item in val:
280 if not isinstance(item, dict):
281 continue
282 entries.append(
283 ChangelogEntry(
284 commit_id=_str_val(item, "commit_id"),
285 message=_str_val(item, "message"),
286 sem_ver_bump=_sem_ver_bump_val(item),
287 breaking_changes=_str_list(item, "breaking_changes"),
288 author=_str_val(item, "author"),
289 committed_at=_str_val(item, "committed_at"),
290 agent_id=_str_val(item, "agent_id"),
291 model_id=_str_val(item, "model_id"),
292 )
293 )
294 return entries
295
296
297 # ---------------------------------------------------------------------------
298 # msgpack I/O helpers
299 # ---------------------------------------------------------------------------
300
301
302 def write_text_atomic(
303 path: pathlib.Path,
304 text: str,
305 encoding: str = "utf-8",
306 ) -> None:
307 """Write *text* to *path* atomically and durably.
308
309 Uses the same ``mkstemp`` β†’ ``flush`` β†’ ``fsync`` β†’ ``rename`` pattern as
310 :func:`_write_msgpack_atomic` so every VCS state file β€” HEAD, branch refs,
311 merge state, config β€” is protected against:
312
313 * **Torn writes**: a crash mid-write leaves the old file intact (atomic rename).
314 * **Page-cache loss**: a power loss after rename cannot produce an empty file
315 (fsync ensures data is durable before the rename commits it).
316 * **Concurrent writer races**: every caller gets its own mkstemp name so two
317 concurrent writers to the same path do not corrupt each other's temp file.
318
319 ``fsync`` failure is non-fatal and is silently ignored β€” some virtual
320 filesystems (tmpfs, certain Docker volumes) do not support it. The
321 atomicity guarantee (torn-write protection) still holds.
322
323 Raises:
324 ValueError: If *path*'s parent directory is a symlink (symlink-swap
325 attack guard β€” writing into a symlinked directory would
326 redirect the write outside the repository root).
327 """
328 path.parent.mkdir(parents=True, exist_ok=True)
329 # Guard: reject symlinked parent directories to prevent symlink-swap
330 # attacks that redirect store writes to attacker-controlled locations.
331 assert_not_symlink(path.parent, label=f"write target parent ({path.parent.name}/)")
332 fd, tmp_str = tempfile.mkstemp(dir=path.parent, prefix=".muse-tmp-")
333 tmp = pathlib.Path(tmp_str)
334 try:
335 with os.fdopen(fd, "w", encoding=encoding) as fh:
336 fh.write(text)
337 fh.flush()
338 try:
339 os.fsync(fh.fileno())
340 except OSError as exc:
341 if exc.errno not in (errno.EINVAL, None):
342 raise
343 tmp.replace(path)
344 except OSError:
345 tmp.unlink(missing_ok=True)
346 raise
347
348
349 def write_branch_ref(
350 repo_root: pathlib.Path,
351 branch: str,
352 commit_id: str,
353 ) -> None:
354 """Atomically update the branch tip pointer in ``.muse/refs/heads/<branch>``.
355
356 This is the **canonical** way to advance a branch ref. All commands that
357 record a new commit on a branch β€” ``commit``, ``merge``, ``cherry-pick``,
358 ``revert``, ``reset``, ``pull``, ``rebase`` β€” must call this function
359 rather than writing the ref file directly.
360
361 Using a bare ``path.write_text()`` is forbidden for ref files because:
362 * It is not atomic β€” a crash mid-write leaves a zero-length or partial file,
363 orphaning all commits reachable only from this branch.
364 * It is not fsynced β€” a power loss after the write syscall returns but
365 before the page cache is flushed produces the same corruption.
366
367 Args:
368 repo_root: Repository root (parent of ``.muse/``).
369 branch: Branch name; validated before use.
370 commit_id: 64-char SHA-256 hex string of the new tip commit.
371
372 Raises:
373 ValueError: If *branch* or *commit_id* is invalid.
374 """
375 from muse.core.validation import validate_branch_name
376 validate_branch_name(branch)
377 if not re.fullmatch(r"[0-9a-f]{64}", commit_id):
378 raise ValueError(f"commit_id must be 64 hex chars, got: {commit_id!r}")
379 ref_path = repo_root / ".muse" / "refs" / "heads" / branch
380 write_text_atomic(ref_path, commit_id)
381
382
383 # Store parent directories that have already been validated as non-symlinks
384 # this process run. The same amortisation strategy as object_store.py's
385 # _validated_object_shards: commits/, snapshots/, tags/ are checked once.
386 _validated_store_parents: set[str] = set()
387
388
389 def _write_msgpack_atomic(
390 path: pathlib.Path,
391 data: "CommitDict | SnapshotDict | TagDict | ReleaseDict",
392 ) -> None:
393 """Serialize *data* to msgpack and atomically replace *path*.
394
395 Uses a unique ``mkstemp`` temp file β†’ ``fsync`` β†’ ``rename`` pattern so:
396 - A crash mid-write never leaves a corrupt store file (atomic rename).
397 - A power loss after rename returns but before the page cache is flushed
398 cannot produce a zero-length or partially-written file (fsync ensures
399 the data is durable before the rename commits it).
400
401 Raises:
402 ValueError: If *path*'s parent directory is a symlink (symlink-swap
403 attack guard).
404 """
405 # Guard: reject symlinked parent directories to prevent symlink-swap
406 # attacks that redirect metadata writes to attacker-controlled locations.
407 # Amortised: after the first write to a given parent directory this process
408 # run the check is skipped β€” the parent cannot become a symlink without
409 # write access to .muse/, which would represent a more fundamental breach.
410 parent_str = str(path.parent)
411 if parent_str not in _validated_store_parents:
412 assert_not_symlink(path.parent, label=f"write target parent ({path.parent.name}/)")
413 _validated_store_parents.add(parent_str)
414 packed = msgpack.packb(data, use_bin_type=True)
415 fd, tmp_str = tempfile.mkstemp(dir=path.parent, prefix=".muse-tmp-")
416 tmp = pathlib.Path(tmp_str)
417 try:
418 with os.fdopen(fd, "wb") as fh:
419 fh.write(packed)
420 fh.flush()
421 try:
422 if sys.platform == "darwin":
423 # F_BARRIERFSYNC (85) provides the same APFS crash-safety
424 # guarantee as F_FULLFSYNC at ~50Γ— lower latency (~0.1 ms
425 # vs ~5 ms per call). Fall through to os.fsync() if the
426 # kernel rejects it (old macOS or non-APFS filesystem).
427 try:
428 fcntl.fcntl(fh.fileno(), 85) # F_BARRIERFSYNC
429 except OSError:
430 os.fsync(fh.fileno())
431 else:
432 os.fsync(fh.fileno())
433 except OSError as exc:
434 if exc.errno != errno.EINVAL:
435 raise
436 tmp.replace(path)
437 except OSError:
438 tmp.unlink(missing_ok=True)
439 raise
440
441
442 def safe_unpackb(
443 raw: bytes,
444 *,
445 context: str = "",
446 max_bytes: int = MAX_MSGPACK_BYTES,
447 strict_map_key: bool = True,
448 allow_binary: bool = False,
449 ) -> MsgpackValue:
450 """Deserialize msgpack bytes with strict, configurable safety limits.
451
452 This is the single canonical entry-point for all in-memory msgpack
453 deserialization in Muse. Every callsite that receives untrusted bytes
454 (stdin, network, cache files) must go through here.
455
456 Args:
457 raw: The msgpack bytes to unpack.
458 context: Human-readable label for error messages (e.g. "stdin",
459 "server response"). Omit for internal callers.
460 max_bytes: Hard cap on ``len(raw)`` checked *before* any parsing,
461 preventing OOM from size-bomb payloads. Defaults to
462 :data:`MAX_MSGPACK_BYTES` (64 MiB). Use
463 :data:`MAX_PACK_MSGPACK_BYTES` for pack/bundle data.
464 strict_map_key: When ``True`` (default) only ``str`` keys are accepted
465 in msgpack maps, matching Muse's own serialisation
466 convention. Set ``False`` only for legacy formats that
467 may carry integer keys (e.g. the staging index v1).
468 allow_binary: When ``False`` (default) binary blobs are rejected
469 (``max_bin_len=0``), enforcing that commit/snapshot/tag
470 records never embed raw bytes. Set ``True`` for
471 pack/bundle payloads that legitimately carry blob content.
472
473 Raises:
474 ValueError: If ``len(raw)`` exceeds *max_bytes* β€” checked before any
475 allocation from ``unpackb``.
476 msgpack.UnpackException: If the bytes are not valid msgpack.
477 msgpack.ExtraData: If there is trailing data after the top-level value.
478 """
479 n = len(raw)
480 if n > max_bytes:
481 label = f" ({context})" if context else ""
482 raise ValueError(
483 f"Msgpack payload{label} is {n:,} bytes β€” exceeds the "
484 f"{max_bytes // (1024 * 1024)} MiB safety cap. "
485 "Possible size-bomb or corrupted input."
486 )
487 result: MsgpackValue = msgpack.unpackb(
488 raw,
489 raw=False,
490 strict_map_key=strict_map_key,
491 max_str_len=_MSGPACK_MAX_STR_LEN,
492 max_bin_len=_MSGPACK_MAX_BIN_LEN_PACK if allow_binary else _MSGPACK_MAX_BIN_LEN,
493 max_array_len=_MSGPACK_MAX_ARRAY_LEN,
494 max_map_len=_MSGPACK_MAX_MAP_LEN,
495 )
496 return result
497
498
499 def read_msgpack_file(
500 path: pathlib.Path,
501 *,
502 max_bytes: int = MAX_MSGPACK_BYTES,
503 strict_map_key: bool = True,
504 allow_binary: bool = False,
505 ) -> MsgpackValue:
506 """Read a msgpack file and deserialize it with strict safety limits.
507
508 The file size is checked via :func:`os.stat` *before* ``read_bytes()``
509 is called, so a multi-GiB file never causes an allocation. The
510 deserialized value is then bounded by per-field limits via
511 :func:`safe_unpackb`.
512
513 This is the canonical helper for all file-based msgpack reads outside
514 of the core commit/snapshot/tag store (which uses the private
515 ``_read_msgpack``).
516
517 Raises:
518 OSError: If the file size exceeds *max_bytes*.
519 ValueError: Forwarded from :func:`safe_unpackb` if deserialization
520 limits are violated.
521 msgpack.UnpackException: If the file is not valid msgpack.
522 """
523 size = path.stat().st_size
524 if size > max_bytes:
525 raise OSError(
526 f"Msgpack file {path.name!r} is {size:,} bytes β€” exceeds the "
527 f"{max_bytes // (1024 * 1024)} MiB safety cap. "
528 "File may be corrupt or tampered."
529 )
530 return safe_unpackb(
531 path.read_bytes(),
532 context=path.name,
533 max_bytes=max_bytes,
534 strict_map_key=strict_map_key,
535 allow_binary=allow_binary,
536 )
537
538
539 def _read_msgpack(path: pathlib.Path) -> MsgpackValue:
540 """Read and unpack a msgpack file, enforcing size and per-value limits.
541
542 Raises :exc:`OSError` if the file exceeds :data:`MAX_MSGPACK_BYTES` β€”
543 the stat check fires *before* ``read_bytes()`` so a 10 GiB file never
544 causes an allocation. Callers that want ``None`` on failure should
545 catch :exc:`Exception` and return ``None``.
546
547 Per-value limits (``max_str_len``, ``max_bin_len``, etc.) bound memory
548 use during unpacking even when the total file size is within the cap.
549
550 The size limit is read from :data:`MAX_MSGPACK_BYTES` at call time so
551 that test patches to the module attribute are respected.
552 """
553 size = path.stat().st_size
554 limit = MAX_MSGPACK_BYTES
555 if size > limit:
556 raise OSError(
557 f"Msgpack file {path.name!r} is {size:,} bytes β€” exceeds the "
558 f"{limit:,} bytes read limit."
559 )
560 return safe_unpackb(path.read_bytes(), context=path.name, max_bytes=limit)
561
562
563 def _read_msgpack_dict(path: pathlib.Path) -> MsgpackDict:
564 """Read a msgpack file and return the top-level mapping.
565
566 Raises :exc:`TypeError` if the deserialized value is not a ``dict`` β€”
567 which indicates a corrupt or tampered store file. Callers that want
568 ``None`` on failure should catch :exc:`Exception` and return ``None``.
569 """
570 raw = _read_msgpack(path)
571 if not isinstance(raw, dict):
572 raise TypeError(
573 f"Expected dict from {path}, got {type(raw).__name__!r}"
574 )
575 return raw
576
577
578 _COMMITS_DIR = "commits"
579 _SNAPSHOTS_DIR = "snapshots"
580 _TAGS_DIR = "tags"
581 _RELEASES_DIR = "releases"
582
583 class ReleaseDict(TypedDict):
584 """JSON-serialisable representation of a ReleaseRecord."""
585
586 release_id: str
587 repo_id: str
588 tag: str
589 semver: SemVerTag
590 channel: str
591 commit_id: str
592 snapshot_id: str
593 title: str
594 body: str
595 changelog: list[ChangelogEntry]
596 agent_id: str
597 model_id: str
598 is_draft: bool
599 gpg_signature: str
600 created_at: str
601
602 # ---------------------------------------------------------------------------
603 # HEAD file β€” typed I/O
604 # ---------------------------------------------------------------------------
605 #
606 # Muse HEAD format
607 # ----------------
608 # The ``.muse/HEAD`` file is always one of two self-describing forms:
609 #
610 # ref: refs/heads/<branch> β€” symbolic ref; HEAD points to a branch
611 # commit: <sha256> β€” detached HEAD; HEAD points to a commit
612 #
613 # The ``ref:`` prefix is adopted from Git because it is the right design:
614 # a file that can hold two semantically different things should say which
615 # one it holds. The ``commit:`` prefix for detached HEAD is a Muse
616 # extension β€” Git uses a bare SHA, which is ambiguous (SHA-1? SHA-256?).
617 # Muse makes the hash algorithm implicit in the prefix, leaving the door
618 # open for future algorithm identifiers without changing the parsing rule.
619 #
620 # There is no backward-compatibility layer; every write site uses
621 # ``write_head_branch`` / ``write_head_commit`` and every read site uses
622 # ``read_head`` / ``read_current_branch``.
623
624
625 class SymbolicHead(TypedDict):
626 """HEAD points to a named branch."""
627
628 kind: Literal["branch"]
629 branch: str
630
631
632 class DetachedHead(TypedDict):
633 """HEAD points directly to a commit (detached HEAD state)."""
634
635 kind: Literal["commit"]
636 commit_id: str
637
638
639 HeadState = SymbolicHead | DetachedHead
640
641
642 def read_head(repo_root: pathlib.Path) -> HeadState:
643 """Parse ``.muse/HEAD`` and return a typed :data:`HeadState`.
644
645 Raises :exc:`ValueError` for any content that does not match the two
646 expected forms, and when the HEAD file does not exist (uninitialised or
647 corrupt repository), so callers never receive an ambiguous raw string or
648 an unhandled :exc:`FileNotFoundError`.
649 """
650 head_path = repo_root / ".muse" / "HEAD"
651 try:
652 raw = head_path.read_text(encoding="utf-8").strip()
653 except FileNotFoundError:
654 raise ValueError(
655 f"Repository HEAD file missing: {head_path}\n"
656 "The repository may be uninitialised. Run 'muse init' to fix it."
657 )
658 if raw.startswith("ref: refs/heads/"):
659 branch = raw.removeprefix("ref: refs/heads/").strip()
660 validate_branch_name(branch)
661 return SymbolicHead(kind="branch", branch=branch)
662 if raw.startswith("commit: "):
663 commit_id = raw.removeprefix("commit: ").strip()
664 if not re.fullmatch(r"[0-9a-f]{64}", commit_id):
665 raise ValueError(f"Malformed commit ID in HEAD: {commit_id!r}")
666 return DetachedHead(kind="commit", commit_id=commit_id)
667 raise ValueError(
668 f"Malformed HEAD: {raw!r}. "
669 "Expected 'ref: refs/heads/<branch>' or 'commit: <sha256>'."
670 )
671
672
673 def read_current_branch(repo_root: pathlib.Path) -> str:
674 """Return the currently checked-out branch name.
675
676 Raises :exc:`ValueError` when the repository is in detached HEAD state
677 so callers that cannot operate without a branch get a clear error
678 rather than silently receiving a commit ID as a branch name.
679 """
680 state = read_head(repo_root)
681 if state["kind"] != "branch":
682 raise ValueError(
683 "Repository is in detached HEAD state. "
684 "Run 'muse checkout <branch>' to return to a branch."
685 )
686 return state["branch"]
687
688
689 def write_head_branch(repo_root: pathlib.Path, branch: str) -> None:
690 """Write a symbolic ref to ``.muse/HEAD`` atomically.
691
692 Format: ``ref: refs/heads/<branch>`` β€” self-describing; the ``ref:``
693 prefix unambiguously identifies the entry as a symbolic reference.
694
695 Uses :func:`write_text_atomic` so a crash or power loss during ``muse
696 checkout`` or ``muse init`` cannot corrupt or zero-out HEAD.
697 """
698 validate_branch_name(branch)
699 write_text_atomic(repo_root / ".muse" / "HEAD", f"ref: refs/heads/{branch}\n")
700
701
702 def write_head_commit(repo_root: pathlib.Path, commit_id: str) -> None:
703 """Write a direct commit reference to ``.muse/HEAD`` atomically (detached HEAD).
704
705 Format: ``commit: <sha256>`` β€” the ``commit:`` prefix is a Muse
706 extension that makes the entry self-describing in all states. Unlike
707 Git (which stores a bare hash), this makes the hash type explicit and
708 leaves room for future algorithm prefixes without parsing heuristics.
709
710 Uses :func:`write_text_atomic` so a crash or power loss cannot zero-out HEAD.
711 """
712 if not re.fullmatch(r"[0-9a-f]{64}", commit_id):
713 raise ValueError(f"commit_id must be a 64-char hex string, got: {commit_id!r}")
714 write_text_atomic(repo_root / ".muse" / "HEAD", f"commit: {commit_id}\n")
715
716
717 # ---------------------------------------------------------------------------
718 # Wire-format TypedDicts (JSON-serialisable, used by to_dict / from_dict)
719 # ---------------------------------------------------------------------------
720
721
722 class CommitDict(TypedDict, total=False):
723 """JSON-serialisable representation of a CommitRecord.
724
725 ``structured_delta`` is the typed delta produced by the domain plugin's
726 ``diff()`` at commit time. ``None`` on the initial commit (no parent to
727 diff against).
728
729 ``sem_ver_bump`` and ``breaking_changes`` are semantic versioning
730 metadata. Absent (treated as ``"none"`` / ``[]``) for older records and
731 non-code domains.
732
733 Agent provenance fields (all optional, default ``""`` for older records):
734
735 ``agent_id`` Stable identity string for the committing agent or human
736 (e.g. ``"counterpoint-bot"`` or ``"gabriel"``).
737 ``model_id`` Model identifier when the author is an AI agent
738 (e.g. ``"claude-opus-4"``). Empty for human authors.
739 ``toolchain_id`` Toolchain that produced the commit
740 (e.g. ``"cursor-agent-v2"``).
741 ``prompt_hash`` SHA-256 of the instruction/prompt that triggered this
742 commit. Privacy-preserving: the hash identifies the
743 prompt without storing its content.
744 ``signature`` Base64url-encoded Ed25519 signature (no padding, 86 chars)
745 over the provenance payload (SHA-256 of commit_id + authorship
746 fields). Verifiable with
747 :func:`muse.core.provenance.verify_commit_ed25519` using the
748 embedded ``signer_public_key``.
749 ``signer_public_key`` Base64url-encoded raw Ed25519 public key bytes (32 bytes β†’
750 43 chars). Embedded in the commit so that verification is fully
751 offline β€” no hub lookup required.
752 ``signer_key_id`` First 16 hex chars of SHA-256(raw public key bytes).
753 ``format_version`` Schema evolution counter. Each phase of the Muse
754 supercharge plan that extends the commit record bumps
755 this value. Readers use it to know which optional fields
756 are present:
757
758 - ``1`` β€” base record (commit_id, snapshot_id, parent, message, author)
759 - ``2`` β€” adds ``structured_delta`` (Phase 1: Typed Delta Algebra)
760 - ``3`` β€” adds ``sem_ver_bump``, ``breaking_changes``
761 (Phase 2: Domain Schema)
762 - ``4`` β€” adds agent provenance: ``agent_id``, ``model_id``,
763 ``toolchain_id``, ``prompt_hash``, ``signature``,
764 ``signer_key_id`` (Phase 4: Agent Identity)
765 - ``5`` β€” adds CRDT annotation fields: ``reviewed_by``
766 (ORSet of reviewer IDs), ``test_runs``
767 (GCounter of test-run events)
768 - ``6`` β€” legacy; HMAC-SHA256 provenance signing (removed).
769 Records at this version carry an HMAC signature
770 that can no longer be verified β€” treat as unsigned.
771 - ``7`` β€” Ed25519 provenance signing: ``signature`` is a
772 base64url Ed25519 signature over the provenance payload;
773 ``signer_public_key`` contains the signer's raw public
774 key bytes (base64url, 43 chars) for offline verification.
775
776 Old records without this field default to ``1``.
777 """
778
779 commit_id: str
780 repo_id: str
781 branch: str
782 snapshot_id: str
783 message: str
784 committed_at: str
785 parent_commit_id: str | None
786 parent2_commit_id: str | None
787 author: str
788 metadata: Metadata
789 structured_delta: StructuredDelta | None
790 sem_ver_bump: SemVerBump
791 breaking_changes: list[str]
792 agent_id: str
793 model_id: str
794 toolchain_id: str
795 prompt_hash: str
796 signature: str
797 signer_public_key: str
798 signer_key_id: str
799 format_version: int
800 # CRDT-backed annotation fields (format_version >= 5).
801 # ``reviewed_by`` is the logical state of an ORSet: a list of unique
802 # reviewer identifiers. Merging two records takes the union (set join).
803 # ``test_runs`` is a GCounter: monotonically increasing test-run count.
804 # Both fields are absent in older records and default to [] / 0.
805 reviewed_by: list[str]
806 test_runs: int
807
808
809 class SnapshotDict(TypedDict):
810 """JSON-serialisable representation of a SnapshotRecord."""
811
812 snapshot_id: str
813 manifest: Manifest
814 directories: list[str]
815 created_at: str
816 note: str
817
818
819 class TagDict(TypedDict):
820 """JSON-serialisable representation of a TagRecord."""
821
822 tag_id: str
823 repo_id: str
824 commit_id: str
825 tag: str
826 created_at: str
827
828
829
830 # ---------------------------------------------------------------------------
831 # Data classes
832 # ---------------------------------------------------------------------------
833
834
835 @dataclass
836 class CommitRecord:
837 """An immutable commit record stored as a JSON file under .muse/commits/.
838
839 ``sem_ver_bump`` and ``breaking_changes`` are populated by the commit command
840 when a code-domain delta is available. They default to ``"none"`` and ``[]``
841 for older records and non-code domains.
842
843 Agent provenance fields default to ``""`` so that existing JSON without
844 them deserialises without error. See :class:`CommitDict` for field semantics.
845 """
846
847 commit_id: str
848 repo_id: str
849 branch: str
850 snapshot_id: str
851 message: str
852 committed_at: datetime.datetime
853 parent_commit_id: str | None = None
854 parent2_commit_id: str | None = None
855 author: str = ""
856 metadata: Metadata = field(default_factory=dict)
857 structured_delta: StructuredDelta | None = None
858 sem_ver_bump: SemVerBump = "none"
859 breaking_changes: list[str] = field(default_factory=list)
860 agent_id: str = ""
861 model_id: str = ""
862 toolchain_id: str = ""
863 prompt_hash: str = ""
864 signature: str = ""
865 signer_public_key: str = ""
866 signer_key_id: str = ""
867 #: Schema evolution counter β€” see :class:`CommitDict` for the version table.
868 #: Version 5 adds ``reviewed_by`` / ``test_runs`` (CRDT fields).
869 #: Version 7 switches commit signing from HMAC to Ed25519; adds ``signer_public_key``.
870 format_version: int = 7
871 reviewed_by: list[str] = field(default_factory=list)
872 test_runs: int = 0
873
874 def to_dict(self) -> CommitDict:
875 return CommitDict(
876 commit_id=self.commit_id,
877 repo_id=self.repo_id,
878 branch=self.branch,
879 snapshot_id=self.snapshot_id,
880 message=self.message,
881 committed_at=self.committed_at.isoformat(),
882 parent_commit_id=self.parent_commit_id,
883 parent2_commit_id=self.parent2_commit_id,
884 author=self.author,
885 metadata=dict(self.metadata),
886 structured_delta=self.structured_delta,
887 sem_ver_bump=self.sem_ver_bump,
888 breaking_changes=list(self.breaking_changes),
889 agent_id=self.agent_id,
890 model_id=self.model_id,
891 toolchain_id=self.toolchain_id,
892 prompt_hash=self.prompt_hash,
893 signature=self.signature,
894 signer_public_key=self.signer_public_key,
895 signer_key_id=self.signer_key_id,
896 format_version=self.format_version,
897 reviewed_by=list(self.reviewed_by),
898 test_runs=self.test_runs,
899 )
900
901 @classmethod
902 def from_msgpack(cls, d: MsgpackDict) -> "CommitRecord":
903 """Deserialise a :class:`CommitRecord` from a raw msgpack mapping.
904
905 Uses typed accessor helpers (:func:`_str_val`, :func:`_str_or_none`,
906 etc.) so every field access is type-safe without ``# type: ignore``.
907 Runtime guards on the three ID fields fail loud β€” a corrupt commit_id
908 would propagate into path construction which is a security boundary.
909 """
910 committed_at_str = _str_val(d, "committed_at")
911 try:
912 committed_at = datetime.datetime.fromisoformat(committed_at_str)
913 except ValueError as exc:
914 # Do NOT substitute datetime.now(). committed_at feeds directly into
915 # compute_commit_id(), so a fake timestamp would produce a CommitRecord
916 # whose hash never matches the stored commit_id. Worse, the substitution
917 # masks the corruption from write_commit's self-repair check: write_commit
918 # compares stored commit_id (intact) and sees a match, so it silently
919 # skips overwriting the corrupt file. The commit then becomes permanently
920 # unreadable. Raising here lets write_commit's `except Exception` branch
921 # detect the corruption and overwrite with the incoming good record.
922 raise ValueError(
923 f"Commit record has missing or unparseable committed_at "
924 f"({committed_at_str!r}): {exc}"
925 ) from exc
926
927 # Runtime type guards β€” fail loud rather than silently carrying
928 # non-string IDs into path construction (a security boundary).
929 commit_id = _str_val(d, "commit_id")
930 if not commit_id:
931 raise TypeError(f"commit_id must be a non-empty str, got {d.get('commit_id')!r}")
932 snapshot_id = _str_val(d, "snapshot_id")
933 if not snapshot_id:
934 raise TypeError(f"snapshot_id must be a non-empty str, got {d.get('snapshot_id')!r}")
935 branch = _str_val(d, "branch")
936 if not branch:
937 raise TypeError(f"branch must be a non-empty str, got {d.get('branch')!r}")
938
939 raw_delta = d.get("structured_delta")
940 structured_delta: StructuredDelta | None = None
941 if isinstance(raw_delta, dict) and _as_structured_delta(raw_delta):
942 structured_delta = raw_delta
943
944 return cls(
945 commit_id=commit_id,
946 repo_id=_str_val(d, "repo_id"),
947 branch=branch,
948 snapshot_id=snapshot_id,
949 message=_str_val(d, "message"),
950 committed_at=committed_at,
951 parent_commit_id=_str_or_none(d, "parent_commit_id"),
952 parent2_commit_id=_str_or_none(d, "parent2_commit_id"),
953 author=_str_val(d, "author"),
954 metadata=_str_dict(d, "metadata"),
955 structured_delta=structured_delta,
956 sem_ver_bump=_sem_ver_bump_val(d),
957 breaking_changes=_str_list(d, "breaking_changes"),
958 agent_id=_str_val(d, "agent_id"),
959 model_id=_str_val(d, "model_id"),
960 toolchain_id=_str_val(d, "toolchain_id"),
961 prompt_hash=_str_val(d, "prompt_hash"),
962 signature=_str_val(d, "signature"),
963 signer_public_key=_str_val(d, "signer_public_key"),
964 signer_key_id=_str_val(d, "signer_key_id"),
965 format_version=_int_val(d, "format_version", 1),
966 reviewed_by=_str_list(d, "reviewed_by"),
967 test_runs=_int_val(d, "test_runs"),
968 )
969
970 @classmethod
971 def from_dict(cls, d: "CommitDict") -> "CommitRecord":
972 """Construct a :class:`CommitRecord` from a fully typed :class:`CommitDict`.
973
974 The caller guarantees that *d* is structurally valid β€” all field types
975 match the TypedDict declaration. Use :meth:`from_msgpack` when
976 deserialising untrusted wire or disk data.
977 """
978 committed_at_str = d.get("committed_at", "")
979 try:
980 committed_at = datetime.datetime.fromisoformat(committed_at_str)
981 except ValueError as exc:
982 # Do NOT substitute datetime.now(). committed_at feeds directly into
983 # compute_commit_id(), so a fake timestamp would produce a CommitRecord
984 # whose hash never matches the stored commit_id. When this bad record is
985 # written via write_commit / apply_pack, read_commit's hash verification
986 # always fails and the commit is permanently unreadable.
987 raise ValueError(
988 f"Commit record has missing or unparseable committed_at "
989 f"({committed_at_str!r}): {exc}"
990 ) from exc
991 return cls(
992 commit_id=d.get("commit_id", ""),
993 repo_id=d.get("repo_id", ""),
994 branch=d.get("branch", ""),
995 snapshot_id=d.get("snapshot_id", ""),
996 message=d.get("message", ""),
997 committed_at=committed_at,
998 parent_commit_id=d.get("parent_commit_id"),
999 parent2_commit_id=d.get("parent2_commit_id"),
1000 author=d.get("author", ""),
1001 metadata=dict(d.get("metadata") or {}),
1002 structured_delta=d.get("structured_delta"),
1003 sem_ver_bump=d.get("sem_ver_bump", "none"),
1004 breaking_changes=list(d.get("breaking_changes") or []),
1005 agent_id=d.get("agent_id", ""),
1006 model_id=d.get("model_id", ""),
1007 toolchain_id=d.get("toolchain_id", ""),
1008 prompt_hash=d.get("prompt_hash", ""),
1009 signature=d.get("signature", ""),
1010 signer_public_key=d.get("signer_public_key", ""),
1011 signer_key_id=d.get("signer_key_id", ""),
1012 format_version=d.get("format_version", 1) or 1,
1013 reviewed_by=list(d.get("reviewed_by") or []),
1014 test_runs=d.get("test_runs", 0) or 0,
1015 )
1016
1017
1018 @dataclass
1019 class SnapshotRecord:
1020 """An immutable snapshot record stored as a msgpack file under .muse/snapshots/.
1021
1022 ``directories`` is the sorted list of workspace-relative POSIX directory
1023 paths that were explicitly tracked at snapshot time. It is included in
1024 the snapshot ID hash so that a directory rename produces a distinct
1025 snapshot even when file contents are unchanged.
1026
1027 ``note`` is an optional human-readable label set at capture time.
1028 """
1029
1030 snapshot_id: str
1031 manifest: Manifest
1032 directories: list[str] = field(default_factory=list)
1033 created_at: datetime.datetime = field(
1034 default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
1035 )
1036 note: str = ""
1037
1038 def to_dict(self) -> SnapshotDict:
1039 return SnapshotDict(
1040 snapshot_id=self.snapshot_id,
1041 manifest=self.manifest,
1042 directories=list(self.directories),
1043 created_at=self.created_at.isoformat(),
1044 note=self.note,
1045 )
1046
1047 @classmethod
1048 def from_msgpack(cls, d: MsgpackDict) -> "SnapshotRecord":
1049 """Deserialise a :class:`SnapshotRecord` from a raw msgpack mapping."""
1050 created_at_str = _str_val(d, "created_at")
1051 try:
1052 created_at = datetime.datetime.fromisoformat(created_at_str)
1053 except ValueError as exc:
1054 raise ValueError(
1055 f"Snapshot record has missing or unparseable created_at "
1056 f"({created_at_str!r}): {exc}"
1057 ) from exc
1058 raw_dirs = d.get("directories")
1059 directories = (
1060 [v for v in raw_dirs if isinstance(v, str)]
1061 if isinstance(raw_dirs, list)
1062 else []
1063 )
1064 return cls(
1065 snapshot_id=_str_val(d, "snapshot_id"),
1066 manifest=_str_dict(d, "manifest"),
1067 directories=directories,
1068 created_at=created_at,
1069 note=_str_val(d, "note"),
1070 )
1071
1072 @classmethod
1073 def from_dict(cls, d: "SnapshotDict") -> "SnapshotRecord":
1074 """Construct a :class:`SnapshotRecord` from a typed :class:`SnapshotDict`."""
1075 created_at_str = d.get("created_at", "")
1076 try:
1077 created_at = datetime.datetime.fromisoformat(created_at_str)
1078 except ValueError as exc:
1079 raise ValueError(
1080 f"Snapshot record has missing or unparseable created_at "
1081 f"({created_at_str!r}): {exc}"
1082 ) from exc
1083 return cls(
1084 snapshot_id=d.get("snapshot_id", ""),
1085 manifest=dict(d.get("manifest") or {}),
1086 directories=list(d.get("directories") or []),
1087 created_at=created_at,
1088 note=d.get("note", ""),
1089 )
1090
1091
1092 @dataclass
1093 class TagRecord:
1094 """A semantic tag attached to a commit."""
1095
1096 tag_id: str
1097 repo_id: str
1098 commit_id: str
1099 tag: str
1100 created_at: datetime.datetime = field(
1101 default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
1102 )
1103
1104 def to_dict(self) -> TagDict:
1105 return TagDict(
1106 tag_id=self.tag_id,
1107 repo_id=self.repo_id,
1108 commit_id=self.commit_id,
1109 tag=self.tag,
1110 created_at=self.created_at.isoformat(),
1111 )
1112
1113 @classmethod
1114 def from_msgpack(cls, d: MsgpackDict) -> "TagRecord":
1115 """Deserialise a :class:`TagRecord` from a raw msgpack mapping."""
1116 created_at_str = _str_val(d, "created_at")
1117 try:
1118 created_at = datetime.datetime.fromisoformat(created_at_str)
1119 except ValueError:
1120 created_at = datetime.datetime.now(datetime.timezone.utc)
1121 return cls(
1122 tag_id=_str_val(d, "tag_id") or str(uuid.uuid4()),
1123 repo_id=_str_val(d, "repo_id"),
1124 commit_id=_str_val(d, "commit_id"),
1125 tag=_str_val(d, "tag"),
1126 created_at=created_at,
1127 )
1128
1129 @classmethod
1130 def from_dict(cls, d: "TagDict") -> "TagRecord":
1131 """Construct a :class:`TagRecord` from a typed :class:`TagDict`."""
1132 created_at_str = d.get("created_at", "")
1133 try:
1134 created_at = datetime.datetime.fromisoformat(created_at_str)
1135 except ValueError:
1136 created_at = datetime.datetime.now(datetime.timezone.utc)
1137 return cls(
1138 tag_id=d.get("tag_id", "") or str(uuid.uuid4()),
1139 repo_id=d.get("repo_id", ""),
1140 commit_id=d.get("commit_id", ""),
1141 tag=d.get("tag", ""),
1142 created_at=created_at,
1143 )
1144
1145
1146 @dataclass
1147 class ReleaseRecord:
1148 """A versioned release attached to a commit.
1149
1150 A release is richer than a Git tag:
1151
1152 - ``semver`` is a parsed struct (major/minor/patch/pre/build) β€” queryable
1153 by version component without string parsing.
1154 - ``channel`` replaces the boolean ``is_prerelease`` flag with a named
1155 distribution channel: stable | beta | alpha | nightly.
1156 - ``changelog`` is auto-generated from the typed ``sem_ver_bump`` and
1157 ``breaking_changes`` fields on commits since the previous release, so no
1158 conventional-commit parsing is needed.
1159 - ``snapshot_id`` makes the release byte-for-byte reproducible from the
1160 content-addressed object store forever.
1161 - ``agent_id`` / ``model_id`` surface AI provenance from the tip commit.
1162 - ``gpg_signature`` signs the release for tamper-evident distribution.
1163 """
1164
1165 release_id: str
1166 repo_id: str
1167 tag: str
1168 semver: SemVerTag
1169 channel: ReleaseChannel
1170 commit_id: str
1171 snapshot_id: str
1172 title: str
1173 body: str
1174 changelog: list[ChangelogEntry]
1175 agent_id: str = ""
1176 model_id: str = ""
1177 is_draft: bool = False
1178 gpg_signature: str = ""
1179 created_at: datetime.datetime = field(
1180 default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
1181 )
1182
1183 def to_dict(self) -> ReleaseDict:
1184 d = ReleaseDict(
1185 release_id=self.release_id,
1186 repo_id=self.repo_id,
1187 tag=self.tag,
1188 semver=self.semver,
1189 channel=self.channel,
1190 commit_id=self.commit_id,
1191 snapshot_id=self.snapshot_id,
1192 title=self.title,
1193 body=self.body,
1194 changelog=list(self.changelog),
1195 agent_id=self.agent_id,
1196 model_id=self.model_id,
1197 is_draft=self.is_draft,
1198 gpg_signature=self.gpg_signature,
1199 created_at=self.created_at.isoformat(),
1200 )
1201 return d
1202
1203 @classmethod
1204 def from_msgpack(cls, d: MsgpackDict) -> "ReleaseRecord":
1205 """Deserialise a :class:`ReleaseRecord` from a raw msgpack mapping."""
1206 created_at_str = _str_val(d, "created_at")
1207 try:
1208 created_at = datetime.datetime.fromisoformat(created_at_str)
1209 except ValueError:
1210 created_at = datetime.datetime.now(datetime.timezone.utc)
1211 channel = _CHANNEL_MAP.get(_str_val(d, "channel", "stable"), "stable")
1212 is_draft_val = d.get("is_draft", False)
1213 return cls(
1214 release_id=_str_val(d, "release_id"),
1215 repo_id=_str_val(d, "repo_id"),
1216 tag=_str_val(d, "tag"),
1217 semver=_parse_semver_tag(d, "semver"),
1218 channel=channel,
1219 commit_id=_str_val(d, "commit_id"),
1220 snapshot_id=_str_val(d, "snapshot_id"),
1221 title=_str_val(d, "title"),
1222 body=_str_val(d, "body"),
1223 changelog=_parse_changelog_entries(d, "changelog"),
1224 agent_id=_str_val(d, "agent_id"),
1225 model_id=_str_val(d, "model_id"),
1226 is_draft=bool(is_draft_val),
1227 gpg_signature=_str_val(d, "gpg_signature"),
1228 created_at=created_at,
1229 )
1230
1231 @classmethod
1232 def from_dict(cls, d: "ReleaseDict") -> "ReleaseRecord":
1233 """Construct a :class:`ReleaseRecord` from a typed :class:`ReleaseDict`."""
1234 try:
1235 created_at = datetime.datetime.fromisoformat(d["created_at"])
1236 except ValueError:
1237 created_at = datetime.datetime.now(datetime.timezone.utc)
1238 channel = _CHANNEL_MAP.get(d["channel"], "stable")
1239 return cls(
1240 release_id=d["release_id"],
1241 repo_id=d["repo_id"],
1242 tag=d["tag"],
1243 semver=d["semver"],
1244 channel=channel,
1245 commit_id=d["commit_id"],
1246 snapshot_id=d["snapshot_id"],
1247 title=d["title"],
1248 body=d["body"],
1249 changelog=d["changelog"],
1250 agent_id=d["agent_id"],
1251 model_id=d["model_id"],
1252 is_draft=d["is_draft"],
1253 gpg_signature=d["gpg_signature"],
1254 created_at=created_at,
1255 )
1256
1257
1258 # ---------------------------------------------------------------------------
1259 # Path helpers
1260 # ---------------------------------------------------------------------------
1261
1262
1263 def _commits_dir(repo_root: pathlib.Path) -> pathlib.Path:
1264 return repo_root / ".muse" / _COMMITS_DIR
1265
1266
1267 def _snapshots_dir(repo_root: pathlib.Path) -> pathlib.Path:
1268 return repo_root / ".muse" / _SNAPSHOTS_DIR
1269
1270
1271 def _tags_dir(repo_root: pathlib.Path, repo_id: str) -> pathlib.Path:
1272 # Validate repo_id to prevent path traversal via crafted IDs from remote data.
1273 # Uses a best-effort guard (no path separators or dot-sequences).
1274 if "/" in repo_id or "\\" in repo_id or ".." in repo_id or not repo_id:
1275 raise ValueError(f"repo_id {repo_id!r} contains unsafe path components.")
1276 return repo_root / ".muse" / _TAGS_DIR / repo_id
1277
1278
1279 def _releases_dir(repo_root: pathlib.Path, repo_id: str) -> pathlib.Path:
1280 if "/" in repo_id or "\\" in repo_id or ".." in repo_id or not repo_id:
1281 raise ValueError(f"repo_id {repo_id!r} contains unsafe path components.")
1282 return repo_root / ".muse" / _RELEASES_DIR / repo_id
1283
1284
1285 def _commit_path(repo_root: pathlib.Path, commit_id: str) -> pathlib.Path:
1286 return _commits_dir(repo_root) / f"{commit_id}.msgpack"
1287
1288
1289 def _snapshot_path(repo_root: pathlib.Path, snapshot_id: str) -> pathlib.Path:
1290 return _snapshots_dir(repo_root) / f"{snapshot_id}.msgpack"
1291
1292
1293 # ---------------------------------------------------------------------------
1294 # Commit operations
1295 # ---------------------------------------------------------------------------
1296
1297
1298 def commit_exists(repo_root: pathlib.Path, commit_id: str) -> bool:
1299 """Return ``True`` when the commit file for *commit_id* is present on disk.
1300
1301 Uses the canonical ``.msgpack`` path β€” the same path that :func:`write_commit`
1302 writes and :func:`read_commit` reads. Callers that need to filter a list of
1303 candidate have-anchors to those the local BFS can actually stop at should use
1304 this function rather than building the path themselves.
1305 """
1306 return _commit_path(repo_root, commit_id).exists()
1307
1308
1309 # Commit directories that have been created this process run. Eliminates
1310 # the per-write mkdir(exist_ok=True) stat call after the first commit.
1311 _known_commits_dirs: set[str] = set()
1312
1313
1314 def write_commit(repo_root: pathlib.Path, commit: CommitRecord) -> None:
1315 """Persist a commit record to ``.muse/commits/<commit_id>.msgpack``.
1316
1317 Idempotent: if the file already exists and is a valid commit record, the
1318 write is skipped. If the file exists but is *corrupt*, a CRITICAL is
1319 logged and the file is overwritten with the incoming (good) record β€” the
1320 store prefers a live good record over a corrupt existing one.
1321
1322 Raises:
1323 OSError: If an existing record's ``commit_id`` field does not match
1324 the filename (data-integrity violation β€” indicates a tampered
1325 or severely corrupted store).
1326 """
1327 cd = _commits_dir(repo_root)
1328 cd_str = str(cd)
1329 if cd_str not in _known_commits_dirs:
1330 cd.mkdir(parents=True, exist_ok=True)
1331 _known_commits_dirs.add(cd_str)
1332 # Validate the INCOMING record before touching disk. from_dict callers
1333 # that silently substituted now() for a corrupt committed_at produce a
1334 # record whose content hash never matches commit_id β€” if we wrote it,
1335 # every subsequent read_commit would return None (permanent data loss).
1336 # This is the last line of defence before the atomic write.
1337 try:
1338 _verify_commit_id(commit, commit.commit_id, pathlib.Path("<incoming>"))
1339 except OSError as exc:
1340 raise ValueError(
1341 f"Refusing to write commit {commit.commit_id[:8]!r}: "
1342 f"incoming record failed hash verification β€” {exc}"
1343 ) from exc
1344 path = _commit_path(repo_root, commit.commit_id)
1345 if path.exists():
1346 try:
1347 existing = CommitRecord.from_msgpack(_read_msgpack_dict(path))
1348 if existing.commit_id != commit.commit_id:
1349 # The stored record's commit_id does not match the filename.
1350 # This should never happen with correct Muse code; it signals
1351 # tampering or catastrophic bit-rot.
1352 raise OSError(
1353 f"Store integrity violation: commit file {path.name!r} "
1354 f"contains commit_id {existing.commit_id[:8]!r}, "
1355 f"expected {commit.commit_id[:8]!r}"
1356 )
1357 # Content-hash verification: re-derive the commit ID from the core
1358 # fields (snapshot_id, message, committed_at, parent IDs) to catch
1359 # bit-rot that leaves commit_id intact but corrupts the content.
1360 # Wrap OSError as ValueError so the except Exception branch below
1361 # triggers repair rather than the except OSError re-raise.
1362 try:
1363 _verify_commit_id(existing, commit.commit_id, path)
1364 except OSError as verify_exc:
1365 raise ValueError(str(verify_exc)) from verify_exc
1366 # Existing record is valid and hash-verified β€” normal idempotent skip.
1367 logger.debug("⚠️ Commit %s already exists β€” skipped", commit.commit_id[:8])
1368 return
1369 except OSError:
1370 raise # propagate hard integrity violations (commit_id field mismatch)
1371 except Exception as exc:
1372 # Existing file is corrupt (parse failure or hash mismatch) β€”
1373 # overwrite it with the incoming (good) record.
1374 logger.critical(
1375 "❌ Corrupt commit file %s β€” overwriting with incoming record: %s",
1376 path, exc,
1377 )
1378 _write_msgpack_atomic(path, commit.to_dict())
1379 logger.debug("βœ… Stored commit %s branch=%r", commit.commit_id[:8], commit.branch)
1380
1381
1382 def _verify_commit_id(
1383 record: "CommitRecord", expected_id: str, path: pathlib.Path
1384 ) -> None:
1385 """Re-derive the commit ID from stored fields and assert it matches *expected_id*.
1386
1387 A bit flip that keeps msgpack structure intact but corrupts a core commit
1388 field (``snapshot_id``, ``message``, ``committed_at``, or any parent ID)
1389 will produce a different derived hash, exposing the corruption.
1390
1391 **Coverage:** fields in :func:`~muse.core.snapshot.compute_commit_id` β€”
1392 ``parent_commit_id``, ``parent2_commit_id``, ``snapshot_id``, ``message``,
1393 and ``committed_at``. Metadata-only fields (``branch``, ``author``,
1394 ``repo_id``, ``metadata``) are NOT included in the commit ID by design β€”
1395 those can be updated post-hoc via :func:`overwrite_commit`.
1396
1397 Raises:
1398 OSError: If the recomputed ID does not match *expected_id*, indicating
1399 silent field-level corruption that survived msgpack structural
1400 validation.
1401 """
1402 parent_ids: list[str] = []
1403 if record.parent_commit_id:
1404 parent_ids.append(record.parent_commit_id)
1405 if record.parent2_commit_id:
1406 parent_ids.append(record.parent2_commit_id)
1407 recomputed = compute_commit_id(
1408 parent_ids,
1409 record.snapshot_id,
1410 record.message,
1411 record.committed_at.isoformat(),
1412 )
1413 if recomputed != expected_id:
1414 logger.critical(
1415 "❌ Commit %s failed content-hash verification β€” "
1416 "core fields are corrupt (snapshot_id, message, committed_at, or "
1417 "parent IDs). Expected %s, recomputed %s. "
1418 "Run `muse verify-pack` to audit the full store.",
1419 expected_id[:8],
1420 expected_id[:16],
1421 recomputed[:16],
1422 )
1423 raise OSError(
1424 f"Commit {expected_id[:8]}… failed content-hash verification. "
1425 f"Core fields (snapshot_id, message, committed_at, parent IDs) "
1426 f"have been silently corrupted in {path.name}. "
1427 "Run `muse verify-pack` to audit the full store."
1428 )
1429
1430
1431 def _verify_snapshot_id(
1432 record: "SnapshotRecord", expected_id: str, path: pathlib.Path
1433 ) -> None:
1434 """Re-derive the snapshot ID from the manifest and assert it matches *expected_id*.
1435
1436 The snapshot ID is a hash of every ``path β†’ object_id`` pair in the
1437 manifest, so any bit flip in any file path or object ID β€” however subtle β€”
1438 produces a different hash. This catches the class of corruptions that
1439 keep msgpack structure valid while silently altering manifest entries.
1440
1441 Raises:
1442 OSError: If the recomputed ID does not match *expected_id*, indicating
1443 silent manifest corruption.
1444 """
1445 recomputed = compute_snapshot_id(record.manifest, record.directories)
1446 if recomputed != expected_id:
1447 logger.critical(
1448 "❌ Snapshot %s failed content-hash verification β€” "
1449 "manifest entries are corrupt. Expected %s, recomputed %s. "
1450 "Run `muse verify-pack` to audit the full store.",
1451 expected_id[:8],
1452 expected_id[:16],
1453 recomputed[:16],
1454 )
1455 raise OSError(
1456 f"Snapshot {expected_id[:8]}… failed content-hash verification. "
1457 f"One or more manifest entries (file paths or object IDs) have "
1458 f"been silently corrupted in {path.name}. "
1459 "Run `muse verify-pack` to audit the full store."
1460 )
1461
1462
1463 def read_commit(repo_root: pathlib.Path, commit_id: str) -> CommitRecord | None:
1464 """Load a commit record by ID, or ``None`` if it does not exist or is corrupt.
1465
1466 Every read re-verifies the commit ID by recomputing it from the stored
1467 core fields (``snapshot_id``, ``message``, ``committed_at``, parent IDs).
1468 This catches bit-flip corruptions that survive msgpack structural
1469 validation but silently alter the commit's essential content.
1470
1471 Callers that need to distinguish "not found" from "corrupt" should use
1472 :func:`read_commit_result` instead.
1473
1474 Callers that accept user-supplied or remote-supplied commit IDs should
1475 validate the ID with :func:`~muse.core.validation.validate_ref_id` before
1476 calling this function. This function itself accepts any string to support
1477 internal uses with computed IDs.
1478 """
1479 path = _commit_path(repo_root, commit_id)
1480 if not path.exists():
1481 return None
1482 try:
1483 record = CommitRecord.from_msgpack(_read_msgpack_dict(path))
1484 _verify_commit_id(record, commit_id, path)
1485 return record
1486 except Exception as exc:
1487 logger.critical("❌ Corrupt commit file %s: %s", path, exc)
1488 return None
1489
1490
1491 class CommitReadOk(TypedDict):
1492 status: str
1493 commit: CommitRecord
1494
1495
1496 class CommitReadNotFound(TypedDict):
1497 status: str
1498
1499
1500 class CommitReadCorrupt(TypedDict):
1501 status: str
1502 path: str
1503 error: str
1504
1505
1506 def commit_read_is_ok(
1507 r: CommitReadOk | CommitReadNotFound | CommitReadCorrupt,
1508 ) -> TypeGuard[CommitReadOk]:
1509 """``True`` when *r* is a successful :func:`read_commit_result`."""
1510 return r["status"] == "ok"
1511
1512
1513 def commit_read_is_not_found(
1514 r: CommitReadOk | CommitReadNotFound | CommitReadCorrupt,
1515 ) -> TypeGuard[CommitReadNotFound]:
1516 """``True`` when *r* represents a missing commit."""
1517 return r["status"] == "not_found"
1518
1519
1520 def commit_read_is_corrupt(
1521 r: CommitReadOk | CommitReadNotFound | CommitReadCorrupt,
1522 ) -> TypeGuard[CommitReadCorrupt]:
1523 """``True`` when *r* represents a corrupt commit file."""
1524 return r["status"] == "corrupt"
1525
1526
1527 def read_commit_result(
1528 repo_root: pathlib.Path, commit_id: str
1529 ) -> CommitReadOk | CommitReadNotFound | CommitReadCorrupt:
1530 """Load a commit record with a typed result that distinguishes all outcomes.
1531
1532 Unlike :func:`read_commit` (which returns ``CommitRecord | None``),
1533 this function enables callers to take different actions depending on
1534 whether a commit is absent, corrupt, or healthy.
1535
1536 Returns one of:
1537
1538 * ``{"status": "ok", "commit": CommitRecord}``
1539 * ``{"status": "not_found"}``
1540 * ``{"status": "corrupt", "path": str, "error": str}``
1541 """
1542 path = _commit_path(repo_root, commit_id)
1543 if not path.exists():
1544 return CommitReadNotFound(status="not_found")
1545 try:
1546 record = CommitRecord.from_msgpack(_read_msgpack_dict(path))
1547 _verify_commit_id(record, commit_id, path)
1548 return CommitReadOk(status="ok", commit=record)
1549 except Exception as exc:
1550 logger.critical("❌ Corrupt commit file %s: %s", path, exc)
1551 return CommitReadCorrupt(status="corrupt", path=str(path), error=str(exc))
1552
1553
1554 def overwrite_commit(repo_root: pathlib.Path, commit: CommitRecord) -> None:
1555 """Overwrite an existing commit record on disk (e.g. for annotation updates).
1556
1557 Unlike :func:`write_commit`, this function always writes the record even if
1558 the file already exists. Use only for annotation fields
1559 (``reviewed_by``, ``test_runs``) that are semantically additive β€” never
1560 for changing history (commit_id, parent, snapshot, message).
1561
1562 Args:
1563 repo_root: Repository root.
1564 commit: The updated commit record to persist.
1565 """
1566 _commits_dir(repo_root).mkdir(parents=True, exist_ok=True)
1567 path = _commit_path(repo_root, commit.commit_id)
1568 _write_msgpack_atomic(path, commit.to_dict())
1569 logger.debug("βœ… Updated annotation on commit %s", commit.commit_id[:8])
1570
1571
1572 def update_commit_metadata(
1573 repo_root: pathlib.Path,
1574 commit_id: str,
1575 key: str,
1576 value: str,
1577 ) -> bool:
1578 """Set a single string key in a commit's metadata dict.
1579
1580 Returns ``True`` on success, ``False`` if the commit is not found.
1581 """
1582 commit = read_commit(repo_root, commit_id)
1583 if commit is None:
1584 logger.warning("⚠️ Commit %s not found β€” cannot update metadata", commit_id[:8])
1585 return False
1586 commit.metadata[key] = value
1587 path = _commit_path(repo_root, commit_id)
1588 _write_msgpack_atomic(path, commit.to_dict())
1589 logger.debug("βœ… Set %s=%r on commit %s", key, value, commit_id[:8])
1590 return True
1591
1592
1593 def get_head_commit_id(repo_root: pathlib.Path, branch: str) -> str | None:
1594 """Return the commit ID at HEAD of *branch*, or ``None`` for an empty branch."""
1595 validate_branch_name(branch)
1596 ref_path = repo_root / ".muse" / "refs" / "heads" / branch
1597 if not ref_path.exists():
1598 return None
1599 raw = ref_path.read_text(encoding="utf-8").strip()
1600 return raw if raw else None
1601
1602
1603 def get_all_branch_heads(repo_root: pathlib.Path) -> BranchHeads:
1604 """Return a mapping of branch name β†’ commit ID for every branch in *repo_root*.
1605
1606 Reads all ref files under ``.muse/refs/heads/``. Branches whose ref file
1607 is empty or contains an invalid commit ID are silently skipped.
1608
1609 Args:
1610 repo_root: Repository root directory (contains ``.muse/``).
1611
1612 Returns:
1613 ``{branch_name: commit_id}`` for every non-empty branch ref.
1614 """
1615 heads_dir = repo_root / ".muse" / "refs" / "heads"
1616 if not heads_dir.is_dir():
1617 return {}
1618 result: BranchHeads = {}
1619 for ref_file in heads_dir.iterdir():
1620 if not ref_file.is_file():
1621 continue
1622 raw = ref_file.read_text(encoding="utf-8").strip()
1623 if raw:
1624 result[ref_file.name] = raw
1625 return result
1626
1627
1628 def get_head_snapshot_id(
1629 repo_root: pathlib.Path,
1630 repo_id: str,
1631 branch: str,
1632 ) -> str | None:
1633 """Return the snapshot_id at HEAD of *branch*, or ``None``."""
1634 commit_id = get_head_commit_id(repo_root, branch)
1635 if commit_id is None:
1636 return None
1637 commit = read_commit(repo_root, commit_id)
1638 if commit is None:
1639 return None
1640 return commit.snapshot_id
1641
1642
1643 def resolve_commit_ref(
1644 repo_root: pathlib.Path,
1645 repo_id: str,
1646 branch: str,
1647 ref: str | None,
1648 ) -> CommitRecord | None:
1649 """Resolve a commit reference to a ``CommitRecord``.
1650
1651 *ref* may be:
1652 - ``None`` / ``"HEAD"`` β€” the most recent commit on *branch*.
1653 - ``"HEAD~N"`` or ``"<sha>~N"`` β€” walk *N* first-parent steps back.
1654 - A full or abbreviated commit SHA β€” resolved by prefix scan.
1655
1656 Performs a safe prefix scan (glob metacharacters stripped from *ref*) so
1657 user-supplied references cannot glob the entire commits directory.
1658 """
1659 import re as _re
1660
1661 if ref is None or ref.upper() == "HEAD":
1662 commit_id = get_head_commit_id(repo_root, branch)
1663 if commit_id is None:
1664 return None
1665 return read_commit(repo_root, commit_id)
1666
1667 # Handle tilde notation: HEAD~N or <sha>~N
1668 _tilde_match = _re.fullmatch(r"(.+?)~(\d+)", ref, _re.IGNORECASE)
1669 if _tilde_match:
1670 base_ref, steps_str = _tilde_match.group(1), _tilde_match.group(2)
1671 steps = int(steps_str)
1672 # Resolve the base ref (may itself be HEAD or a SHA)
1673 base = resolve_commit_ref(repo_root, repo_id, branch, base_ref if base_ref.upper() != "HEAD" else None)
1674 if base is None:
1675 return None
1676 commit = base
1677 for _ in range(steps):
1678 if commit.parent_commit_id is None:
1679 return None # walked past the initial commit
1680 next_commit = read_commit(repo_root, commit.parent_commit_id)
1681 if next_commit is None:
1682 return None
1683 commit = next_commit
1684 return commit
1685
1686 # Sanitize user-supplied ref before using it in any filesystem operation.
1687 safe_ref = sanitize_glob_prefix(ref)
1688
1689 # Try exact match β€” only if it looks like a full 64-char hex ID.
1690 try:
1691 validate_ref_id(safe_ref)
1692 exact: CommitRecord | None = read_commit(repo_root, safe_ref)
1693 if exact is not None:
1694 return exact
1695 except ValueError:
1696 pass # Not a full hex ID β€” fall through to prefix scan.
1697
1698 # Prefix scan with sanitized prefix.
1699 return _find_commit_by_prefix(repo_root, safe_ref)
1700
1701
1702 def _find_commit_by_prefix(
1703 repo_root: pathlib.Path, prefix: str
1704 ) -> CommitRecord | None:
1705 """Find the first commit whose ID starts with *prefix*.
1706
1707 Glob metacharacters are stripped from *prefix* before use to prevent
1708 callers from turning a targeted lookup into an arbitrary directory scan.
1709 """
1710 commits_dir = _commits_dir(repo_root)
1711 if not commits_dir.exists():
1712 return None
1713 safe_prefix = sanitize_glob_prefix(prefix)
1714 for path in commits_dir.glob(f"{safe_prefix}*.msgpack"):
1715 try:
1716 return CommitRecord.from_msgpack(_read_msgpack_dict(path))
1717 except Exception:
1718 continue
1719 return None
1720
1721
1722 def find_commits_by_prefix(
1723 repo_root: pathlib.Path, prefix: str
1724 ) -> list[CommitRecord]:
1725 """Return all commits whose ID starts with *prefix*."""
1726 commits_dir = _commits_dir(repo_root)
1727 if not commits_dir.exists():
1728 return []
1729 safe_prefix = sanitize_glob_prefix(prefix)
1730 results: list[CommitRecord] = []
1731 for path in commits_dir.glob(f"{safe_prefix}*.msgpack"):
1732 try:
1733 results.append(CommitRecord.from_msgpack(_read_msgpack_dict(path)))
1734 except Exception:
1735 continue
1736 return results
1737
1738
1739 def _resolve_branch_commit_id(repo_root: pathlib.Path, branch: str) -> str | None:
1740 """Resolve *branch* to a commit ID, handling both local and remote tracking refs.
1741
1742 Resolution order:
1743 1. Local branch β€” ``.muse/refs/heads/<branch>``
1744 2. Remote tracking ref β€” ``.muse/remotes/<remote>/<name>`` when *branch*
1745 contains a ``/`` (e.g. ``"origin/dev"`` β†’ remote ``"origin"``, name ``"dev"``).
1746 3. Returns ``None`` when neither exists.
1747
1748 Kept in ``store.py`` (not ``cli/config.py``) so any store-layer caller can
1749 resolve remote refs without introducing an upward import dependency.
1750 """
1751 local = get_head_commit_id(repo_root, branch)
1752 if local is not None:
1753 return local
1754 if "/" in branch:
1755 remote, _, name = branch.partition("/")
1756 if name:
1757 ref_path = repo_root / ".muse" / "remotes" / remote / name
1758 if ref_path.is_file():
1759 raw = ref_path.read_text(encoding="utf-8").strip()
1760 return raw if raw else None
1761 return None
1762
1763
1764 def get_commits_for_branch(
1765 repo_root: pathlib.Path,
1766 repo_id: str,
1767 branch: str,
1768 max_count: int = 0,
1769 ) -> list[CommitRecord]:
1770 """Return commits on *branch*, newest first, by walking the parent chain.
1771
1772 *branch* may be a local branch name (``"dev"``) or a remote tracking ref
1773 (``"origin/dev"``). Both are resolved via :func:`_resolve_branch_commit_id`.
1774
1775 Args:
1776 repo_root: Repository root.
1777 repo_id: Repository UUID (reserved for future index use).
1778 branch: Branch name or remote tracking ref to walk from HEAD.
1779 max_count: Stop after this many commits. ``0`` (default) means walk
1780 the entire chain. Pass the caller's ``--max-count`` /
1781 ``-n`` value here so the walk stops early instead of
1782 loading every commit file from disk before the caller
1783 slices the result.
1784 """
1785 commits: list[CommitRecord] = []
1786 commit_id = _resolve_branch_commit_id(repo_root, branch)
1787 seen: set[str] = set()
1788 while commit_id and commit_id not in seen:
1789 if max_count > 0 and len(commits) >= max_count:
1790 break
1791 seen.add(commit_id)
1792 commit = read_commit(repo_root, commit_id)
1793 if commit is None:
1794 break
1795 commits.append(commit)
1796 commit_id = commit.parent_commit_id
1797 return commits
1798
1799
1800 def get_all_commits(repo_root: pathlib.Path) -> list[CommitRecord]:
1801 """Return all commits in the store (order not guaranteed).
1802
1803 Corrupt commit files are skipped with a CRITICAL log entry β€” callers
1804 receive a best-effort list without the corrupt records. Use
1805 :func:`muse.core.verify.run_verify` for a full integrity audit.
1806 """
1807 commits_dir = _commits_dir(repo_root)
1808 if not commits_dir.exists():
1809 return []
1810 results: list[CommitRecord] = []
1811 for path in commits_dir.glob("*.msgpack"):
1812 try:
1813 results.append(CommitRecord.from_msgpack(_read_msgpack_dict(path)))
1814 except Exception as exc:
1815 logger.critical("❌ Corrupt commit file %s β€” skipped in listing: %s", path, exc)
1816 return results
1817
1818
1819 class WalkResult(TypedDict):
1820 """Result of a bounded history walk.
1821
1822 Returned by :func:`walk_commits_between_result` so callers can
1823 distinguish between a complete walk and one that was truncated by the
1824 safety cap.
1825 """
1826
1827 commits: list[CommitRecord]
1828 truncated: bool
1829 count: int
1830
1831
1832 def walk_commits_between(
1833 repo_root: pathlib.Path,
1834 to_commit_id: str,
1835 from_commit_id: str | None = None,
1836 max_commits: int = 10_000,
1837 ) -> list[CommitRecord]:
1838 """Return commits reachable from *to_commit_id*, stopping before *from_commit_id*.
1839
1840 Walks the parent chain from *to_commit_id* backwards. Returns commits in
1841 newest-first order (callers can reverse for oldest-first).
1842
1843 .. note::
1844 If the walk reaches *max_commits* before the chain is exhausted the
1845 result is silently truncated. Use :func:`walk_commits_between_result`
1846 when the caller must know whether truncation occurred.
1847
1848 Args:
1849 repo_root: Repository root.
1850 to_commit_id: Inclusive end of the range.
1851 from_commit_id: Exclusive start; ``None`` means walk to the initial commit.
1852 max_commits: Safety cap β€” default 10 000.
1853
1854 Returns:
1855 List of ``CommitRecord`` objects, newest first.
1856 """
1857 return walk_commits_between_result(
1858 repo_root, to_commit_id, from_commit_id, max_commits
1859 )["commits"]
1860
1861
1862 def walk_commits_between_result(
1863 repo_root: pathlib.Path,
1864 to_commit_id: str,
1865 from_commit_id: str | None = None,
1866 max_commits: int = 10_000,
1867 ) -> WalkResult:
1868 """Bounded history walk with explicit truncation signalling.
1869
1870 Identical to :func:`walk_commits_between` but returns a :class:`WalkResult`
1871 TypedDict that includes ``"truncated": True`` when the safety cap was
1872 reached before the chain was exhausted.
1873
1874 This is the preferred function for any agent or CLI path that must emit
1875 ``"truncated"`` in its JSON output.
1876
1877 Args:
1878 repo_root: Repository root.
1879 to_commit_id: Inclusive end of the range.
1880 from_commit_id: Exclusive start; ``None`` means walk to the initial commit.
1881 max_commits: Safety cap β€” default 10 000.
1882
1883 Returns:
1884 ``WalkResult`` with ``commits``, ``truncated``, and ``count``.
1885 """
1886 commits: list[CommitRecord] = []
1887 seen: set[str] = set()
1888 current_id: str | None = to_commit_id
1889 while current_id and current_id not in seen:
1890 if len(commits) >= max_commits:
1891 return WalkResult(commits=commits, truncated=True, count=len(commits))
1892 seen.add(current_id)
1893 if current_id == from_commit_id:
1894 break
1895 commit = read_commit(repo_root, current_id)
1896 if commit is None:
1897 break
1898 commits.append(commit)
1899 current_id = commit.parent_commit_id
1900 return WalkResult(commits=commits, truncated=False, count=len(commits))
1901
1902
1903 # ---------------------------------------------------------------------------
1904 # Snapshot operations
1905 # ---------------------------------------------------------------------------
1906
1907
1908 def write_snapshot(repo_root: pathlib.Path, snapshot: SnapshotRecord) -> None:
1909 """Persist a snapshot record to ``.muse/snapshots/<snapshot_id>.msgpack``."""
1910 _snapshots_dir(repo_root).mkdir(parents=True, exist_ok=True)
1911 # Validate the INCOMING record before touching disk. A SnapshotRecord
1912 # constructed with a mismatched snapshot_id (from a corrupt bundle, a
1913 # tampered manifest, or a bad from_dict call) would be written successfully
1914 # but read_snapshot would immediately fail hash verification and return None β€”
1915 # the snapshot becomes permanently unreadable. Reject it here.
1916 try:
1917 _verify_snapshot_id(snapshot, snapshot.snapshot_id, pathlib.Path("<incoming>"))
1918 except OSError as exc:
1919 raise ValueError(
1920 f"Refusing to write snapshot {snapshot.snapshot_id[:8]!r}: "
1921 f"incoming record failed hash verification β€” {exc}"
1922 ) from exc
1923 path = _snapshot_path(repo_root, snapshot.snapshot_id)
1924 if path.exists():
1925 # Verify the existing file before skipping β€” a corrupt manifest entry
1926 # (wrong object ID, missing file, injected file) would otherwise be
1927 # permanent: read_snapshot always calls _verify_snapshot_id and returns
1928 # None on mismatch, but write_snapshot would never overwrite it.
1929 try:
1930 existing = SnapshotRecord.from_msgpack(_read_msgpack_dict(path))
1931 _verify_snapshot_id(existing, snapshot.snapshot_id, path)
1932 logger.debug("⚠️ Snapshot %s already exists β€” skipped", snapshot.snapshot_id[:8])
1933 return
1934 except Exception as exc:
1935 logger.critical(
1936 "❌ Corrupt snapshot file %s β€” overwriting with incoming record: %s",
1937 path, exc,
1938 )
1939 _write_msgpack_atomic(path, snapshot.to_dict())
1940 logger.debug("βœ… Stored snapshot %s (%d files, %d dirs)", snapshot.snapshot_id[:8], len(snapshot.manifest), len(snapshot.directories))
1941
1942
1943 def read_snapshot(repo_root: pathlib.Path, snapshot_id: str) -> SnapshotRecord | None:
1944 """Load a snapshot record by ID, or ``None`` if it does not exist or is corrupt.
1945
1946 Every read re-verifies the snapshot ID by recomputing it from the stored
1947 manifest. Any bit flip that alters a file path or object ID in the
1948 manifest β€” even without breaking msgpack structure β€” is caught here.
1949
1950 Callers that need to distinguish "not found" from "corrupt" should use
1951 :func:`read_snapshot_result` instead.
1952
1953 Callers that accept user-supplied or remote-supplied snapshot IDs should
1954 validate the ID with :func:`~muse.core.validation.validate_ref_id` before
1955 calling this function. This function itself accepts any string to support
1956 internal uses with computed IDs.
1957 """
1958 path = _snapshot_path(repo_root, snapshot_id)
1959 if not path.exists():
1960 return None
1961 try:
1962 record = SnapshotRecord.from_msgpack(_read_msgpack_dict(path))
1963 _verify_snapshot_id(record, snapshot_id, path)
1964 return record
1965 except Exception as exc:
1966 logger.critical("❌ Corrupt snapshot file %s: %s", path, exc)
1967 return None
1968
1969
1970 class SnapshotReadOk(TypedDict):
1971 status: str
1972 snapshot: SnapshotRecord
1973
1974
1975 class SnapshotReadNotFound(TypedDict):
1976 status: str
1977
1978
1979 class SnapshotReadCorrupt(TypedDict):
1980 status: str
1981 path: str
1982 error: str
1983
1984
1985 def snapshot_read_is_ok(
1986 r: SnapshotReadOk | SnapshotReadNotFound | SnapshotReadCorrupt,
1987 ) -> TypeGuard[SnapshotReadOk]:
1988 """``True`` when *r* is a successful :func:`read_snapshot_result`."""
1989 return r["status"] == "ok"
1990
1991
1992 def snapshot_read_is_corrupt(
1993 r: SnapshotReadOk | SnapshotReadNotFound | SnapshotReadCorrupt,
1994 ) -> TypeGuard[SnapshotReadCorrupt]:
1995 """``True`` when *r* represents a corrupt snapshot file."""
1996 return r["status"] == "corrupt"
1997
1998
1999 def read_snapshot_result(
2000 repo_root: pathlib.Path, snapshot_id: str
2001 ) -> SnapshotReadOk | SnapshotReadNotFound | SnapshotReadCorrupt:
2002 """Load a snapshot with a typed result that distinguishes all outcomes.
2003
2004 Returns one of:
2005
2006 * ``{"status": "ok", "snapshot": SnapshotRecord}``
2007 * ``{"status": "not_found"}``
2008 * ``{"status": "corrupt", "path": str, "error": str}``
2009 """
2010 path = _snapshot_path(repo_root, snapshot_id)
2011 if not path.exists():
2012 return SnapshotReadNotFound(status="not_found")
2013 try:
2014 record = SnapshotRecord.from_msgpack(_read_msgpack_dict(path))
2015 _verify_snapshot_id(record, snapshot_id, path)
2016 return SnapshotReadOk(status="ok", snapshot=record)
2017 except Exception as exc:
2018 logger.critical("❌ Corrupt snapshot file %s: %s", path, exc)
2019 return SnapshotReadCorrupt(status="corrupt", path=str(path), error=str(exc))
2020
2021
2022 def get_commit_snapshot_manifest(
2023 repo_root: pathlib.Path, commit_id: str
2024 ) -> Manifest | None:
2025 """Return the file manifest for the snapshot attached to *commit_id*, or ``None``."""
2026 commit = read_commit(repo_root, commit_id)
2027 if commit is None:
2028 logger.warning("⚠️ Commit %s not found", commit_id[:8])
2029 return None
2030 snapshot = read_snapshot(repo_root, commit.snapshot_id)
2031 if snapshot is None:
2032 logger.warning(
2033 "⚠️ Snapshot %s referenced by commit %s not found",
2034 commit.snapshot_id[:8],
2035 commit_id[:8],
2036 )
2037 return None
2038 return dict(snapshot.manifest)
2039
2040
2041 def get_head_snapshot_manifest(
2042 repo_root: pathlib.Path, repo_id: str, branch: str
2043 ) -> Manifest | None:
2044 """Return the manifest of the most recent commit on *branch*, or ``None``."""
2045 snapshot_id = get_head_snapshot_id(repo_root, repo_id, branch)
2046 if snapshot_id is None:
2047 return None
2048 snapshot = read_snapshot(repo_root, snapshot_id)
2049 if snapshot is None:
2050 return None
2051 return dict(snapshot.manifest)
2052
2053
2054
2055 # ---------------------------------------------------------------------------
2056 # Tag operations
2057 # ---------------------------------------------------------------------------
2058
2059
2060 def write_tag(repo_root: pathlib.Path, tag: TagRecord) -> None:
2061 """Persist a tag record to ``.muse/tags/<repo_id>/<tag_id>.msgpack``."""
2062 tags_dir = _tags_dir(repo_root, tag.repo_id)
2063 tags_dir.mkdir(parents=True, exist_ok=True)
2064 path = tags_dir / f"{tag.tag_id}.msgpack"
2065 _write_msgpack_atomic(path, tag.to_dict())
2066 logger.debug("βœ… Stored tag %r on commit %s", tag.tag, tag.commit_id[:8])
2067
2068
2069 def get_tags_for_commit(
2070 repo_root: pathlib.Path, repo_id: str, commit_id: str
2071 ) -> list[TagRecord]:
2072 """Return all tags attached to *commit_id*."""
2073 tags_dir = _tags_dir(repo_root, repo_id)
2074 if not tags_dir.exists():
2075 return []
2076 results: list[TagRecord] = []
2077 for path in tags_dir.glob("*.msgpack"):
2078 try:
2079 record = TagRecord.from_msgpack(_read_msgpack_dict(path))
2080 if record.commit_id == commit_id:
2081 results.append(record)
2082 except Exception:
2083 continue
2084 return results
2085
2086
2087 def delete_tag(repo_root: pathlib.Path, repo_id: str, tag_id: str) -> bool:
2088 """Delete the tag file identified by *tag_id*; return True if it existed."""
2089 tags_dir = _tags_dir(repo_root, repo_id)
2090 path = tags_dir / f"{tag_id}.msgpack"
2091 if path.exists():
2092 path.unlink()
2093 logger.debug("πŸ—‘οΈ Deleted tag %s", tag_id)
2094 return True
2095 return False
2096
2097
2098 def get_all_tags(repo_root: pathlib.Path, repo_id: str) -> list[TagRecord]:
2099 """Return all tags in this repository.
2100
2101 Corrupt tag files are skipped with a CRITICAL log entry β€” callers
2102 receive a best-effort list without the corrupt records.
2103 """
2104 tags_dir = _tags_dir(repo_root, repo_id)
2105 if not tags_dir.exists():
2106 return []
2107 results: list[TagRecord] = []
2108 for path in tags_dir.glob("*.msgpack"):
2109 try:
2110 results.append(TagRecord.from_msgpack(_read_msgpack_dict(path)))
2111 except Exception as exc:
2112 logger.critical("❌ Corrupt tag file %s β€” skipped in listing: %s", path, exc)
2113 return results
2114
2115
2116 # ---------------------------------------------------------------------------
2117 # Release operations
2118 # ---------------------------------------------------------------------------
2119
2120
2121 def write_release(repo_root: pathlib.Path, release: ReleaseRecord) -> None:
2122 """Persist a release record to ``.muse/releases/<repo_id>/<release_id>.msgpack``."""
2123 releases_dir = _releases_dir(repo_root, release.repo_id)
2124 releases_dir.mkdir(parents=True, exist_ok=True)
2125 path = releases_dir / f"{release.release_id}.msgpack"
2126 _write_msgpack_atomic(path, release.to_dict())
2127 logger.debug("βœ… Stored release %r (%s)", release.tag, release.release_id[:8])
2128
2129
2130 def read_release(
2131 repo_root: pathlib.Path, repo_id: str, release_id: str
2132 ) -> ReleaseRecord | None:
2133 """Load a release by its UUID, or ``None`` if it does not exist or is corrupt."""
2134 path = _releases_dir(repo_root, repo_id) / f"{release_id}.msgpack"
2135 if not path.exists():
2136 return None
2137 try:
2138 return ReleaseRecord.from_msgpack(_read_msgpack_dict(path))
2139 except Exception as exc:
2140 logger.critical("❌ Corrupt release file %s: %s", path, exc)
2141 return None
2142
2143
2144 def get_release_for_tag(
2145 repo_root: pathlib.Path, repo_id: str, tag: str
2146 ) -> ReleaseRecord | None:
2147 """Return the release whose version tag matches *tag*, or ``None``.
2148
2149 Searches all releases including drafts so callers can inspect or delete
2150 a draft before it is published.
2151 """
2152 for release in list_releases(repo_root, repo_id, include_drafts=True):
2153 if release.tag == tag:
2154 return release
2155 return None
2156
2157
2158 def list_releases(
2159 repo_root: pathlib.Path,
2160 repo_id: str,
2161 channel: ReleaseChannel | None = None,
2162 include_drafts: bool = False,
2163 ) -> list[ReleaseRecord]:
2164 """Return all releases, newest first.
2165
2166 Args:
2167 repo_root: Repository root.
2168 repo_id: Repository UUID.
2169 channel: Filter by channel; ``None`` returns all channels.
2170 include_drafts: When ``False`` (default) draft releases are hidden.
2171 """
2172 releases_dir = _releases_dir(repo_root, repo_id)
2173 if not releases_dir.exists():
2174 return []
2175 results: list[ReleaseRecord] = []
2176 for path in releases_dir.glob("*.msgpack"):
2177 try:
2178 r = ReleaseRecord.from_msgpack(_read_msgpack_dict(path))
2179 except Exception as exc:
2180 logger.critical("❌ Corrupt release file %s β€” skipped in listing: %s", path, exc)
2181 continue
2182 if r.is_draft and not include_drafts:
2183 continue
2184 if channel is not None and r.channel != channel:
2185 continue
2186 results.append(r)
2187 results.sort(key=lambda r: r.created_at, reverse=True)
2188 return results
2189
2190
2191 def delete_release(repo_root: pathlib.Path, repo_id: str, release_id: str) -> bool:
2192 """Delete a release record. Returns ``True`` if it existed.
2193
2194 Callers are responsible for enforcing that only draft releases may be
2195 deleted. This function performs no such guard β€” enforce at the CLI/API
2196 layer.
2197 """
2198 path = _releases_dir(repo_root, repo_id) / f"{release_id}.msgpack"
2199 if path.exists():
2200 path.unlink()
2201 logger.debug("πŸ—‘οΈ Deleted release %s", release_id)
2202 return True
2203 return False
2204
2205
2206 def build_changelog(
2207 repo_root: pathlib.Path,
2208 from_commit_id: str | None,
2209 to_commit_id: str,
2210 max_commits: int = 500,
2211 ) -> list[ChangelogEntry]:
2212 """Walk the commit graph from *to_commit_id* back to *from_commit_id*.
2213
2214 Returns a list of :class:`ChangelogEntry` dicts, oldest first, excluding
2215 merge commits and ``sem_ver_bump="none"`` commits (they carry no user-visible
2216 change). *from_commit_id* is excluded; *to_commit_id* is included.
2217
2218 Args:
2219 repo_root: Repository root.
2220 from_commit_id: The last release commit (exclusive start), or ``None``
2221 for a first release (walks back to the initial commit).
2222 to_commit_id: The HEAD commit to release (inclusive end).
2223 max_commits: Safety cap β€” changelog never exceeds this many entries.
2224 """
2225 raw = walk_commits_between(repo_root, to_commit_id, from_commit_id, max_commits)
2226 entries: list[ChangelogEntry] = []
2227 for commit in reversed(raw): # oldest first
2228 entries.append(ChangelogEntry(
2229 commit_id=commit.commit_id,
2230 message=commit.message,
2231 sem_ver_bump=commit.sem_ver_bump,
2232 breaking_changes=list(commit.breaking_changes),
2233 author=commit.author,
2234 committed_at=commit.committed_at.isoformat(),
2235 agent_id=commit.agent_id,
2236 model_id=commit.model_id,
2237 ))
2238 return entries
2239
2240
2241 # ---------------------------------------------------------------------------
2242 # Remote sync helpers (push/pull)
2243 # ---------------------------------------------------------------------------