"""muse.core.shelf — shelf (stash) layer for the Muse VCS. Everything that reads, writes, or queries shelf entries lives here. Public API ---------- shelf_entry_path On-disk path helper. write_shelf_entry / read_shelf_entry / list_shelf_entries / delete_shelf_entry Core shelf I/O. """ from __future__ import annotations import json as _json import logging import pathlib from muse.core.io import ( MAX_MSGPACK_BYTES, _read_msgpack_dict, _write_shelf_header_atomic, ) from muse.core.paths import shelf_dir as _shelf_dir from muse.core.types import split_id logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Path helper # --------------------------------------------------------------------------- def shelf_entry_path(repo_root: pathlib.Path, entry_id: str) -> pathlib.Path: """Return the on-disk path for a shelf entry. Path shape: ``.muse/shelf//`` (no extension) Shelf entries are stored as individual content-addressed git-header+JSON files (``shelf \\0`` framing), matching the layout of the unified object store. One file per entry means: - Writes are atomic (temp-rename) and do not touch sibling entries. - Deletes are a single ``unlink`` — no JSON array rewrite. - Concurrent saves from multiple agents cannot corrupt one another. - GC reachability walks glob ``shelf//*``. The algorithm segment is extracted from *entry_id*'s prefix so the layout remains correct when a future algorithm (blake3, sha3-256, …) is introduced. Args: repo_root: Repository root directory. entry_id: A ``:`` shelf entry ID. Returns: Absolute path to the shelf entry file (no extension). """ algo, hex_id = split_id(entry_id) return _shelf_dir(repo_root) / algo / hex_id # --------------------------------------------------------------------------- # Shelf I/O # --------------------------------------------------------------------------- def write_shelf_entry(repo_root: pathlib.Path, entry: "dict[str, object]") -> None: """Persist a shelf entry as ``.muse/shelf//`` (git-header+JSON). The file uses the same ``shelf \\0`` framing as commits and snapshots in the unified object store. Each entry is content-addressed by its ``id`` field: - Atomic temp-rename write: a crash mid-write never corrupts sibling entries. - Idempotent: writing the same entry twice produces exactly one file. - Concurrent-safe: two agents shelving simultaneously write different files. Args: repo_root: Repository root directory. entry: A shelf entry dict that must contain an ``id`` field with a ``:`` prefix (e.g. ``sha256:<64-hex>``). Raises: ValueError: If the ``.muse/shelf/`` parent is a symlink (symlink-swap attack guard). OSError: On filesystem errors (disk full, permission denied, etc.). """ entry_id = str(entry.get("id", "")) path = shelf_entry_path(repo_root, entry_id) shelf = _shelf_dir(repo_root) if shelf.is_symlink(): raise ValueError( f".muse/shelf/ is a symlink — refusing to write shelf entry " f"(symlink-swap attack guard)" ) path.parent.mkdir(parents=True, exist_ok=True) _write_shelf_header_atomic(path, entry) def _read_shelf_file(path: pathlib.Path, entry_id_hint: str = "") -> "dict[str, object] | None": """Read a shelf file in either new header+JSON or legacy msgpack format. If *path* is a legacy ``.msgpack`` file, the entry is silently migrated to the new format and the old file deleted. """ if path.stat().st_size > MAX_MSGPACK_BYTES: logger.warning("⚠️ shelf entry %s exceeds size limit — skipping", path.name[:24]) return None try: raw = path.read_bytes() if path.suffix == ".msgpack": data = _read_msgpack_dict(path) # Migrate to new format entry_id = str(data.get("id", "")) or entry_id_hint if entry_id: new_path = path.with_suffix("") _write_shelf_header_atomic(new_path, data) # type: ignore[arg-type] path.unlink(missing_ok=True) else: null_idx = raw.index(b"\0") data = _json.loads(raw[null_idx + 1:].decode("utf-8")) if not isinstance(data, dict): return None return data # type: ignore[return-value] except Exception as exc: logger.warning("⚠️ Could not read shelf entry %s: %s", path.name[:24], exc) return None def read_shelf_entry(repo_root: pathlib.Path, entry_id: str) -> "dict[str, object] | None": """Read and deserialise a shelf entry by its content-addressed ID. Returns ``None`` on any error — missing file, corrupt payload, or oversized file — so callers never need to handle exceptions for routine storage failures. Falls back to the legacy ``.msgpack`` path on miss and silently migrates the entry to the new git-header+JSON format. Args: repo_root: Repository root directory. entry_id: A ``:`` shelf entry ID. Returns: The entry dict on success, or ``None`` if the entry does not exist or cannot be safely deserialised. """ path = shelf_entry_path(repo_root, entry_id) if path.exists(): return _read_shelf_file(path) # Fallback: legacy .msgpack file (silent upgrade to new format) legacy = path.with_suffix(".msgpack") if legacy.exists(): return _read_shelf_file(legacy, entry_id) return None def list_shelf_entries(repo_root: pathlib.Path) -> "list[dict[str, object]]": """Return all shelf entries sorted by ``created_at`` descending (newest first). Globs ``.muse/shelf//*`` matching exactly one algo-level directory depth. Handles both new git-header+JSON files (no extension) and legacy ``.msgpack`` files, migrating the latter on first read. Corrupt or oversized files are silently skipped. Args: repo_root: Repository root directory. Returns: List of entry dicts, newest-first. Empty list when the shelf directory does not exist or contains no valid entries. """ shelf = _shelf_dir(repo_root) if not shelf.is_dir(): return [] entries = [] for path in shelf.glob("*/*"): # Skip temp files and unexpected extensions (only allow .msgpack or none) if path.name.startswith("."): continue if path.suffix not in ("", ".msgpack"): continue data = _read_shelf_file(path) if data is not None: entries.append(data) entries.sort(key=lambda e: str(e.get("created_at", "")), reverse=True) return entries def delete_shelf_entry(repo_root: pathlib.Path, entry_id: str) -> bool: """Delete a shelf entry by its content-addressed ID. Checks both the new (no extension) path and the legacy ``.msgpack`` path. Args: repo_root: Repository root directory. entry_id: A ``:`` shelf entry ID. Returns: ``True`` if a file existed and was removed; ``False`` if absent. Raises: OSError: On filesystem errors other than ``FileNotFoundError``. """ path = shelf_entry_path(repo_root, entry_id) found = False if path.exists(): path.unlink() found = True legacy = path.with_suffix(".msgpack") if legacy.exists(): legacy.unlink() found = True return found