shelf.py
python
sha256:502e812e12319e30302d07d165425ceeb21687ad30e8af3a439b1f6de36c64d6
fix: update stale tag tests to use label for free-form anno…
Sonnet 4.6
19 days ago
| 1 | """``muse shelf`` — first-class named working-tree checkpoints. |
| 2 | |
| 3 | A shelf is a named, content-addressed snapshot of the working tree that has not |
| 4 | been promoted to a commit. Shelves are a first-class design |
| 5 | built for a content-addressed, domain-agnostic, multi-dimensional VCS in an |
| 6 | agent-first era. |
| 7 | |
| 8 | Design principles |
| 9 | ----------------- |
| 10 | - **Named, not indexed.** Every shelf has a unique name. Integer indices are a |
| 11 | lookup convenience, not the primary identity. |
| 12 | - **Full snapshots, not deltas.** The complete working-tree manifest is stored, |
| 13 | so restoration is always unambiguous — even after intervening merges. |
| 14 | - **Already-current detection.** Applying a shelf reports files that were already |
| 15 | at the shelf version (e.g. merged in since shelving) separately from files that |
| 16 | were actually written. Silent no-ops are replaced by explicit accounting. |
| 17 | - **Intent-typed.** Every shelf carries an ``intent_type`` that describes *why* |
| 18 | it was created: ``interrupt``, ``checkpoint``, ``handoff``, ``experiment``, |
| 19 | or ``draft``. |
| 20 | - **Agent-attributed.** ``created_by`` records which agent or human created the |
| 21 | shelf. ``resumable`` marks entries that another agent can continue. |
| 22 | - **GC-integrated.** All object IDs referenced by shelf entries are treated as |
| 23 | reachable during garbage collection. |
| 24 | |
| 25 | CLI |
| 26 | --- |
| 27 | muse shelf — alias for ``muse shelf save`` (no subcommand needed) |
| 28 | muse shelf save [NAME] [-m INTENT] [--intent-type TYPE] [--tag T] [--resumable] |
| 29 | muse shelf list [--json] [--branch B] [--resumable] [--by AGENT_ID] |
| 30 | muse shelf read [NAME|N] [--json] |
| 31 | muse shelf apply [NAME|N] [--json] |
| 32 | muse shelf pop [NAME|N] [--json] |
| 33 | muse shelf drop [NAME|N] [--json] |
| 34 | muse shelf diff [NAME|N] [--json] |
| 35 | |
| 36 | Name format |
| 37 | ----------- |
| 38 | Auto-generated: ``branch/NNN`` (e.g. ``dev/000``, ``main/000``). |
| 39 | Custom name: ``muse shelf save my-auth-refactor`` |
| 40 | Lookup by name or integer index — both always work. |
| 41 | |
| 42 | Intent types |
| 43 | ------------ |
| 44 | interrupt — agent was interrupted mid-task |
| 45 | checkpoint — voluntary save before a risky operation |
| 46 | handoff — passing context to another agent or human |
| 47 | experiment — exploring a hypothesis; may be discarded |
| 48 | draft — work-in-progress, not ready for review |
| 49 | |
| 50 | JSON output (save / apply / pop) |
| 51 | --------------------------------- |
| 52 | :: |
| 53 | |
| 54 | { |
| 55 | "status": "shelved | nothing_to_shelf | applied | popped | dropped", |
| 56 | "id": "sha256:...", |
| 57 | "name": "<name>", |
| 58 | "snapshot_id": "sha256:...", |
| 59 | "parent_commit": "sha256:...", |
| 60 | "branch": "<branch>", |
| 61 | "created_at": "<iso8601>", |
| 62 | "created_by": {"handle": "<name>", "kind": "human | agent"}, |
| 63 | "intent_type": "interrupt | checkpoint | handoff | experiment | draft", |
| 64 | "intent": "<free-text> | null", |
| 65 | "resumable": true | false, |
| 66 | "tags": ["..."], |
| 67 | "files_count": N, |
| 68 | "shelf_size": N |
| 69 | } |
| 70 | |
| 71 | Exit codes |
| 72 | ---------- |
| 73 | 0 — success |
| 74 | 1 — user error (empty shelf, name not found, duplicate name, invalid args) |
| 75 | 2 — not inside a Muse repository |
| 76 | 3 — object store integrity error (missing objects on apply/pop) |
| 77 | """ |
| 78 | |
| 79 | import argparse |
| 80 | import json |
| 81 | import logging |
| 82 | import pathlib |
| 83 | import sys |
| 84 | from collections.abc import Mapping |
| 85 | from typing import TypedDict |
| 86 | |
| 87 | from muse.cli.config import get_config_value |
| 88 | from muse.core.types import JsonValue, Manifest, content_hash, now_utc_iso |
| 89 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 90 | from muse.core.errors import ExitCode |
| 91 | from muse.core.object_store import has_object, read_object, write_object_from_path |
| 92 | from muse.core.repo import require_repo |
| 93 | from muse.core.ignore import is_ignored |
| 94 | from muse.core.ids import hash_snapshot |
| 95 | from muse.core.snapshot import ( |
| 96 | directories_from_manifest, |
| 97 | load_ignore_patterns, |
| 98 | ) |
| 99 | from muse.core.reflog import append_reflog |
| 100 | from muse.core.refs import get_head_commit_id, read_current_branch |
| 101 | from muse.core.commits import read_commit |
| 102 | from muse.core.snapshots import ( |
| 103 | get_head_snapshot_manifest, |
| 104 | read_snapshot, |
| 105 | ) |
| 106 | from muse.core.shelf import ( |
| 107 | delete_shelf_entry as _delete_shelf_entry, |
| 108 | list_shelf_entries as _list_shelf_entries, |
| 109 | read_shelf_entry as _read_shelf_entry, |
| 110 | write_shelf_entry as _write_shelf_entry, |
| 111 | ) |
| 112 | from muse.core.timing import start_timer |
| 113 | from muse.core.validation import sanitize_display |
| 114 | from muse.core.workdir import apply_manifest |
| 115 | from muse.plugins.registry import resolve_plugin |
| 116 | |
| 117 | type _DomainState = dict[str, JsonValue] |
| 118 | type _RestoreCountMap = dict[str, int] |
| 119 | |
| 120 | _VALID_INTENT_TYPES = frozenset({"interrupt", "checkpoint", "handoff", "experiment", "draft"}) |
| 121 | |
| 122 | logger = logging.getLogger(__name__) |
| 123 | |
| 124 | class _CreatedBy(TypedDict): |
| 125 | """Identity of the entity that created a shelf entry.""" |
| 126 | |
| 127 | handle: str # username or agent ID |
| 128 | kind: str # "human" | "agent" |
| 129 | |
| 130 | class _ShelfSaveJson(EnvelopeJson, total=False): |
| 131 | """JSON envelope for ``muse shelf save --json``.""" |
| 132 | |
| 133 | status: str |
| 134 | id: str | None |
| 135 | name: str | None |
| 136 | snapshot_id: str | None |
| 137 | parent_commit: str | None |
| 138 | branch: str |
| 139 | created_at: str | None |
| 140 | created_by: _CreatedBy |
| 141 | intent_type: str |
| 142 | intent: str | None |
| 143 | resumable: bool |
| 144 | tags: list[str] |
| 145 | files_count: int |
| 146 | shelf_size: int |
| 147 | |
| 148 | class _ShelfListEntryJson(TypedDict): |
| 149 | """One shelf entry in the ``muse shelf list --json`` array.""" |
| 150 | |
| 151 | index: int |
| 152 | id: str |
| 153 | name: str |
| 154 | snapshot_id: str |
| 155 | branch: str |
| 156 | created_at: str |
| 157 | created_by: _CreatedBy |
| 158 | intent_type: str |
| 159 | intent: str | None |
| 160 | resumable: bool |
| 161 | tags: list[str] |
| 162 | files_count: int |
| 163 | |
| 164 | class _ShelfListJson(EnvelopeJson): |
| 165 | """JSON envelope for ``muse shelf list --json``.""" |
| 166 | |
| 167 | entries: list[_ShelfListEntryJson] |
| 168 | |
| 169 | class _ShelfReadJson(EnvelopeJson, total=False): |
| 170 | """JSON envelope for ``muse shelf read --json``.""" |
| 171 | |
| 172 | index: int |
| 173 | id: str |
| 174 | name: str |
| 175 | snapshot_id: str |
| 176 | parent_commit: str |
| 177 | branch: str |
| 178 | created_at: str |
| 179 | created_by: _CreatedBy |
| 180 | intent_type: str |
| 181 | intent: str | None |
| 182 | resumable: bool |
| 183 | tags: list[str] |
| 184 | files_count: int |
| 185 | files: list[str] |
| 186 | deleted: list[str] |
| 187 | |
| 188 | class _ShelfApplyJson(EnvelopeJson): |
| 189 | """JSON envelope for ``muse shelf apply --json``.""" |
| 190 | |
| 191 | status: str |
| 192 | name: str |
| 193 | restored: int |
| 194 | already_current: int |
| 195 | deleted: int |
| 196 | shelf_size: int |
| 197 | |
| 198 | class _ShelfPopJson(EnvelopeJson): |
| 199 | """JSON envelope for ``muse shelf pop --json``.""" |
| 200 | |
| 201 | status: str |
| 202 | name: str |
| 203 | restored: int |
| 204 | already_current: int |
| 205 | deleted: int |
| 206 | shelf_size_after: int |
| 207 | |
| 208 | class _ShelfDropJson(EnvelopeJson): |
| 209 | """JSON envelope for ``muse shelf drop --json``.""" |
| 210 | |
| 211 | status: str |
| 212 | name: str |
| 213 | id: str |
| 214 | shelf_size: int |
| 215 | |
| 216 | class _ShelfDiffJson(EnvelopeJson): |
| 217 | """JSON envelope for ``muse shelf diff --json``.""" |
| 218 | |
| 219 | name: str |
| 220 | branch: str |
| 221 | would_restore: list[str] |
| 222 | already_current: list[str] |
| 223 | would_delete: list[str] |
| 224 | |
| 225 | class ShelfEntry(TypedDict): |
| 226 | """A single named checkpoint in the shelf registry. |
| 227 | |
| 228 | ``snapshot`` is the *complete* working-tree manifest (all tracked files and |
| 229 | their object IDs) at the moment the shelf was created. This is the key |
| 230 | design improvement over the delta-based approach: full snapshots enable unambiguous |
| 231 | restoration and explicit already-current detection after intervening merges. |
| 232 | |
| 233 | ``deleted`` lists paths that existed in HEAD at shelf time but were absent |
| 234 | from the working tree — files the user had removed before shelving. |
| 235 | |
| 236 | ``id`` is the sha256 of the entry content (excluding ``id`` itself), making |
| 237 | every shelf entry content-addressed. |
| 238 | """ |
| 239 | |
| 240 | id: str # sha256: of entry content (minus this field) |
| 241 | name: str # unique human-readable identifier within this repo |
| 242 | snapshot: Manifest # complete path → object_id for all tracked files |
| 243 | deleted: list[str] # paths in HEAD that were removed from working tree |
| 244 | snapshot_id: str # sha256: of snapshot (for GC and display) |
| 245 | parent_commit: str # HEAD commit ID at shelf time |
| 246 | branch: str # branch at shelf time |
| 247 | created_at: str # ISO 8601 |
| 248 | created_by: _CreatedBy # {"handle": "<name>", "kind": "human|agent"} |
| 249 | intent_type: str # interrupt | checkpoint | handoff | experiment | draft |
| 250 | intent: str | None # free-text description of what was being done |
| 251 | resumable: bool # whether another agent can continue this work |
| 252 | tags: list[str] # arbitrary labels for filtering |
| 253 | expires_at: str | None # ISO 8601 expiry; null = no expiry |
| 254 | domain_state: _DomainState # per-domain plugin metadata (MIDI beat, etc.) |
| 255 | |
| 256 | # --------------------------------------------------------------------------- |
| 257 | # Internal helpers |
| 258 | # --------------------------------------------------------------------------- |
| 259 | |
| 260 | def _compute_shelf_id(entry_without_id: Mapping[str, object]) -> str: |
| 261 | """Return the content-addressed sha256: ID for a shelf entry. |
| 262 | |
| 263 | The ID is derived from all fields *except* ``id`` itself, sorted for |
| 264 | canonical JSON serialisation. Two entries with identical content always |
| 265 | produce the same ID. |
| 266 | """ |
| 267 | return content_hash(entry_without_id) |
| 268 | |
| 269 | def _load_shelf(root: pathlib.Path) -> list[ShelfEntry]: |
| 270 | """Return all shelf entries sorted newest-first. |
| 271 | |
| 272 | Delegates to :func:`muse.core.store.list_shelf_entries`, which reads |
| 273 | per-entry git-header+JSON files from ``.muse/shelf/<algo>/<hex>``. |
| 274 | Corrupt or oversized files are silently skipped. |
| 275 | """ |
| 276 | raw_entries = _list_shelf_entries(root) |
| 277 | entries: list[ShelfEntry] = [] |
| 278 | for item in raw_entries: |
| 279 | if not isinstance(item, dict): |
| 280 | continue |
| 281 | snapshot = item.get("snapshot") |
| 282 | if not isinstance(snapshot, dict): |
| 283 | continue |
| 284 | safe_snapshot: Manifest = { |
| 285 | k: v for k, v in snapshot.items() |
| 286 | if isinstance(k, str) and isinstance(v, str) |
| 287 | } |
| 288 | raw_deleted = item.get("deleted", []) |
| 289 | safe_deleted: list[str] = ( |
| 290 | [p for p in raw_deleted if isinstance(p, str)] |
| 291 | if isinstance(raw_deleted, list) else [] |
| 292 | ) |
| 293 | # Normalize created_by: old entries stored a plain string; new entries |
| 294 | # store {"handle": ..., "kind": ...}. Deserialize both gracefully. |
| 295 | raw_cb = item.get("created_by", "human") |
| 296 | if isinstance(raw_cb, dict): |
| 297 | created_by_obj = _CreatedBy( |
| 298 | handle=str(raw_cb.get("handle", "human")), |
| 299 | kind=str(raw_cb.get("kind", "human")), |
| 300 | ) |
| 301 | else: |
| 302 | s = str(raw_cb) |
| 303 | created_by_obj = _CreatedBy( |
| 304 | handle=s, |
| 305 | kind="human" if s == "human" else "agent", |
| 306 | ) |
| 307 | entries.append(ShelfEntry( |
| 308 | id=str(item.get("id", "")), |
| 309 | name=str(item.get("name", "")), |
| 310 | snapshot=safe_snapshot, |
| 311 | deleted=safe_deleted, |
| 312 | snapshot_id=str(item.get("snapshot_id", "")), |
| 313 | parent_commit=str(item.get("parent_commit", "")), |
| 314 | branch=str(item.get("branch", "")), |
| 315 | created_at=str(item.get("created_at", "")), |
| 316 | created_by=created_by_obj, |
| 317 | intent_type=str(item.get("intent_type", "checkpoint")), |
| 318 | intent=str(item["intent"]) if item.get("intent") else None, |
| 319 | resumable=bool(item.get("resumable", False)), |
| 320 | tags=[str(t) for t in item.get("tags", []) if isinstance(t, str)], |
| 321 | expires_at=str(item["expires_at"]) if item.get("expires_at") else None, |
| 322 | domain_state=item.get("domain_state") if isinstance(item.get("domain_state"), dict) else {}, |
| 323 | )) |
| 324 | return entries |
| 325 | |
| 326 | def _resolve_entry( |
| 327 | entries: list[ShelfEntry], |
| 328 | name_or_index: str | None, |
| 329 | *, |
| 330 | default: int = 0, |
| 331 | ) -> tuple[int, ShelfEntry]: |
| 332 | """Return ``(index, entry)`` for the given name or integer index. |
| 333 | |
| 334 | Resolution order: |
| 335 | 1. If *name_or_index* is ``None``: use *default* index. |
| 336 | 2. If *name_or_index* is a decimal integer string: treat as 0-based index. |
| 337 | 3. Otherwise: look up by exact name. |
| 338 | |
| 339 | Raises ``ValueError`` with a human-readable message on invalid input. |
| 340 | """ |
| 341 | if not entries: |
| 342 | raise ValueError("No shelf entries.") |
| 343 | |
| 344 | if name_or_index is None: |
| 345 | idx = default |
| 346 | if idx < 0 or idx >= len(entries): |
| 347 | raise ValueError( |
| 348 | f"Shelf index {idx} is out of range (0–{len(entries) - 1})." |
| 349 | ) |
| 350 | return idx, entries[idx] |
| 351 | |
| 352 | # Integer index? |
| 353 | try: |
| 354 | idx = int(name_or_index) |
| 355 | if idx < 0 or idx >= len(entries): |
| 356 | raise ValueError( |
| 357 | f"Shelf index {idx} is out of range (0–{len(entries) - 1})." |
| 358 | ) |
| 359 | return idx, entries[idx] |
| 360 | except ValueError as exc: |
| 361 | if "out of range" in str(exc): |
| 362 | raise |
| 363 | # Not an integer — treat as name. |
| 364 | pass |
| 365 | |
| 366 | for i, e in enumerate(entries): |
| 367 | if e["name"] == name_or_index: |
| 368 | return i, e |
| 369 | |
| 370 | raise ValueError( |
| 371 | f"No shelf entry named {sanitize_display(name_or_index)!r}.\n" |
| 372 | f" Run 'muse shelf list' to see available entries." |
| 373 | ) |
| 374 | |
| 375 | def _generate_name(branch: str, existing_names: set[str]) -> str: |
| 376 | """Generate a unique shelf name from the branch name. |
| 377 | |
| 378 | Format: ``{branch}/{N:03d}`` where N is the lowest integer that doesn't |
| 379 | already exist. Branch characters outside ``[a-zA-Z0-9/_-]`` are replaced |
| 380 | with ``-``. |
| 381 | """ |
| 382 | safe = "".join(c if c.isalnum() or c in "/_-" else "-" for c in branch) |
| 383 | safe = safe.strip("-").strip("/") or "shelf" |
| 384 | n = 0 |
| 385 | while True: |
| 386 | candidate = f"{safe}/{n:03d}" |
| 387 | if candidate not in existing_names: |
| 388 | return candidate |
| 389 | n += 1 |
| 390 | |
| 391 | def _verify_snapshot_objects(root: pathlib.Path, snapshot: Manifest) -> list[str]: |
| 392 | """Return paths in *snapshot* whose object IDs are absent from the object store.""" |
| 393 | return [p for p, oid in snapshot.items() if not has_object(root, oid)] |
| 394 | |
| 395 | def _apply_shelf_snapshot( |
| 396 | root: pathlib.Path, |
| 397 | entry: ShelfEntry, |
| 398 | head_manifest: Manifest, |
| 399 | ) -> _RestoreCountMap: |
| 400 | """Restore the shelf snapshot to the working tree. |
| 401 | |
| 402 | Only files that were **actually dirty** when the shelf was saved are |
| 403 | restored. "Dirty" means the working-tree hash at save time differed |
| 404 | from the source-branch HEAD snapshot. Files that were clean on the |
| 405 | source branch — including those committed there but not yet merged into |
| 406 | the current branch — are intentionally skipped so they do not bleed |
| 407 | into the working tree as phantom modifications. |
| 408 | |
| 409 | Counts: |
| 410 | - **restored**: files written from the object store. |
| 411 | - **already_current**: disk already matches the shelf version. |
| 412 | - **deleted**: paths removed (were in HEAD at save time, deleted by user). |
| 413 | |
| 414 | Returns a dict with keys ``restored``, ``already_current``, ``deleted``. |
| 415 | """ |
| 416 | from muse.core.types import hash_file |
| 417 | |
| 418 | # Load the source-branch HEAD manifest so we can identify which files were |
| 419 | # truly dirty (working-tree hash ≠ source HEAD hash) vs. merely committed |
| 420 | # on the source branch but not yet present on the current branch. |
| 421 | source_head_manifest: Manifest = {} |
| 422 | parent_commit_id = entry.get("parent_commit", "") |
| 423 | if parent_commit_id: |
| 424 | src_commit = read_commit(root, parent_commit_id) |
| 425 | if src_commit is not None: |
| 426 | snap_id = src_commit.snapshot_id |
| 427 | if snap_id: |
| 428 | src_snap = read_snapshot(root, snap_id) |
| 429 | if src_snap is not None: |
| 430 | source_head_manifest = dict(src_snap.manifest) |
| 431 | |
| 432 | restored = 0 |
| 433 | already_current = 0 |
| 434 | |
| 435 | for rel_path, obj_id in entry["snapshot"].items(): |
| 436 | # Skip files that were clean on the source branch — their current |
| 437 | # content is the merge's responsibility, not the shelf's. |
| 438 | if source_head_manifest.get(rel_path) == obj_id: |
| 439 | continue |
| 440 | |
| 441 | dest = root / rel_path |
| 442 | if dest.exists(): |
| 443 | try: |
| 444 | disk_id = hash_file(dest) |
| 445 | except OSError: |
| 446 | disk_id = None |
| 447 | if disk_id == obj_id: |
| 448 | # Disk already has the exact shelf bytes — no write needed. |
| 449 | already_current += 1 |
| 450 | continue |
| 451 | data = read_object(root, obj_id) |
| 452 | if data is None: |
| 453 | logger.warning("⚠️ shelf: object missing for %s — skipping", rel_path) |
| 454 | continue |
| 455 | dest.parent.mkdir(parents=True, exist_ok=True) |
| 456 | dest.write_bytes(data) |
| 457 | restored += 1 |
| 458 | |
| 459 | deleted = 0 |
| 460 | for rel_path in entry["deleted"]: |
| 461 | target = root / rel_path |
| 462 | try: |
| 463 | target.unlink() |
| 464 | deleted += 1 |
| 465 | except FileNotFoundError: |
| 466 | pass # already gone — idempotent |
| 467 | |
| 468 | return {"restored": restored, "already_current": already_current, "deleted": deleted} |
| 469 | |
| 470 | def _entry_files_count(entry: ShelfEntry, head_manifest: Manifest) -> int: |
| 471 | """Count files that differ between the shelf and HEAD (the shelf's change set).""" |
| 472 | changed = sum( |
| 473 | 1 for p, oid in entry["snapshot"].items() |
| 474 | if head_manifest.get(p) != oid |
| 475 | ) |
| 476 | return changed + len(entry["deleted"]) |
| 477 | |
| 478 | def _resolve_created_by(root: pathlib.Path, explicit_by: str) -> _CreatedBy: |
| 479 | """Resolve the created_by identity for a new shelf entry. |
| 480 | |
| 481 | If *explicit_by* is not ``"human"`` (i.e. the caller passed ``--by`` with an |
| 482 | explicit agent ID), return it as-is with ``kind="agent"``. Otherwise read |
| 483 | ``user.handle`` from the repo config (same source ``muse commit`` uses for |
| 484 | ``author``) and return ``{"handle": <handle>, "kind": "human"}``. Falls back |
| 485 | to ``"human"`` when no handle is configured. |
| 486 | """ |
| 487 | if explicit_by != "human": |
| 488 | return _CreatedBy(handle=explicit_by, kind="agent") |
| 489 | handle = get_config_value("user.handle", root) or "human" |
| 490 | return _CreatedBy(handle=handle, kind="human") |
| 491 | |
| 492 | # --------------------------------------------------------------------------- |
| 493 | # Programmatic API (used by checkout / merge --autoshelf) |
| 494 | # --------------------------------------------------------------------------- |
| 495 | |
| 496 | def _shelf_push_programmatic( |
| 497 | root: pathlib.Path, |
| 498 | name: str | None = None, |
| 499 | *, |
| 500 | intent_type: str = "interrupt", |
| 501 | intent: str | None = None, |
| 502 | created_by: str = "muse", |
| 503 | resumable: bool = False, |
| 504 | tags: list[str] | None = None, |
| 505 | domain_state: _DomainState | None = None, |
| 506 | ) -> ShelfEntry | None: |
| 507 | """Save the current working-tree state to the shelf without CLI output. |
| 508 | |
| 509 | Returns the new :class:`ShelfEntry` if changes were shelved, or ``None`` |
| 510 | when the working tree is already clean (nothing to shelf). |
| 511 | |
| 512 | Args: |
| 513 | root: Repository root. |
| 514 | name: Shelf name. Auto-generated if ``None``. |
| 515 | intent_type: One of the :data:`_VALID_INTENT_TYPES`. |
| 516 | intent: Free-text description of the in-progress work. |
| 517 | created_by: Agent ID or ``"human"``. |
| 518 | resumable: Whether another agent can continue this shelf. |
| 519 | tags: Arbitrary string labels. |
| 520 | domain_state: Per-domain plugin metadata. |
| 521 | |
| 522 | Returns: |
| 523 | The :class:`ShelfEntry` that was saved, or ``None`` if the tree was |
| 524 | already clean. |
| 525 | |
| 526 | Raises: |
| 527 | ValueError: If *name* already exists in the shelf registry. |
| 528 | """ |
| 529 | branch = read_current_branch(root) |
| 530 | plugin = resolve_plugin(root) |
| 531 | manifest = plugin.snapshot(root)["files"] |
| 532 | |
| 533 | head_manifest = get_head_snapshot_manifest(root, branch) or {} |
| 534 | if manifest == head_manifest: |
| 535 | return None |
| 536 | |
| 537 | # Compute deleted: in HEAD but genuinely absent from the working tree. |
| 538 | _ignore_patterns = load_ignore_patterns(root) |
| 539 | deleted: list[str] = [ |
| 540 | path for path in head_manifest |
| 541 | if path not in manifest |
| 542 | and not (is_ignored(path, _ignore_patterns) and (root / path).exists()) |
| 543 | ] |
| 544 | |
| 545 | # Write new/modified objects to the store. |
| 546 | for rel_path, object_id in manifest.items(): |
| 547 | if head_manifest.get(rel_path) != object_id: |
| 548 | write_object_from_path(root, object_id, root / rel_path) |
| 549 | |
| 550 | snapshot_id = hash_snapshot(manifest, directories_from_manifest(manifest)) |
| 551 | |
| 552 | # Resolve parent commit. |
| 553 | from muse.core.commits import resolve_commit_ref |
| 554 | ref = resolve_commit_ref(root, branch, None) |
| 555 | parent_commit = ref.commit_id if ref else "" |
| 556 | |
| 557 | # Determine name. |
| 558 | entries = _load_shelf(root) |
| 559 | existing_names = {e["name"] for e in entries} |
| 560 | if name is None: |
| 561 | name = _generate_name(branch, existing_names) |
| 562 | elif name in existing_names: |
| 563 | raise ValueError( |
| 564 | f"A shelf entry named {name!r} already exists. " |
| 565 | "Use a different name or drop the existing entry first." |
| 566 | ) |
| 567 | |
| 568 | created_at = now_utc_iso() |
| 569 | created_by_obj = _resolve_created_by(root, created_by) |
| 570 | entry_without_id = { |
| 571 | "name": name, |
| 572 | "snapshot": manifest, |
| 573 | "deleted": deleted, |
| 574 | "snapshot_id": snapshot_id, |
| 575 | "parent_commit": parent_commit, |
| 576 | "branch": branch, |
| 577 | "created_at": created_at, |
| 578 | "created_by": created_by_obj, |
| 579 | "intent_type": intent_type, |
| 580 | "intent": intent, |
| 581 | "resumable": resumable, |
| 582 | "tags": tags or [], |
| 583 | "expires_at": None, |
| 584 | "domain_state": domain_state or {}, |
| 585 | } |
| 586 | shelf_id = _compute_shelf_id(entry_without_id) |
| 587 | entry = ShelfEntry(id=shelf_id, **entry_without_id) # type: ignore[misc] |
| 588 | |
| 589 | _write_shelf_entry(root, entry) |
| 590 | |
| 591 | apply_manifest(root, manifest, head_manifest) |
| 592 | logger.debug("shelf: saved %d file(s) as %r", len(manifest), name) |
| 593 | return entry |
| 594 | |
| 595 | def _shelf_pop_programmatic( |
| 596 | root: pathlib.Path, |
| 597 | name_or_index: str | int = 0, |
| 598 | ) -> ShelfEntry: |
| 599 | """Restore a shelf entry and remove it from the registry. |
| 600 | |
| 601 | Args: |
| 602 | root: Repository root. |
| 603 | name_or_index: Name string or integer index (0 = most recent). |
| 604 | |
| 605 | Returns: |
| 606 | The :class:`ShelfEntry` that was applied and removed. |
| 607 | |
| 608 | Raises: |
| 609 | ValueError: If the shelf is empty or the entry is not found. |
| 610 | RuntimeError: If objects are missing from the object store. |
| 611 | """ |
| 612 | entries = _load_shelf(root) |
| 613 | if not entries: |
| 614 | raise ValueError("No shelf entries.") |
| 615 | |
| 616 | raw = str(name_or_index) if isinstance(name_or_index, int) else name_or_index |
| 617 | idx, entry = _resolve_entry(entries, raw) |
| 618 | |
| 619 | missing = _verify_snapshot_objects(root, entry["snapshot"]) |
| 620 | if missing: |
| 621 | raise RuntimeError( |
| 622 | f"Shelf entry {entry['name']!r} is missing {len(missing)} object(s) " |
| 623 | f"from the object store: {', '.join(sorted(missing))}" |
| 624 | ) |
| 625 | |
| 626 | branch = read_current_branch(root) |
| 627 | head_manifest = get_head_snapshot_manifest(root, branch) or {} |
| 628 | |
| 629 | _delete_shelf_entry(root, entry["id"]) |
| 630 | _apply_shelf_snapshot(root, entry, head_manifest) |
| 631 | logger.debug("shelf: restored %r", entry["name"]) |
| 632 | return entry |
| 633 | |
| 634 | # --------------------------------------------------------------------------- |
| 635 | # CLI subcommand registration |
| 636 | # --------------------------------------------------------------------------- |
| 637 | |
| 638 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 639 | """Register the ``muse shelf`` subcommand tree.""" |
| 640 | parser = subparsers.add_parser( |
| 641 | "shelf", |
| 642 | help="Save, inspect, and restore named working-tree checkpoints.", |
| 643 | description=__doc__, |
| 644 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 645 | ) |
| 646 | parser.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.") |
| 647 | subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") |
| 648 | |
| 649 | # ── save ────────────────────────────────────────────────────────────────── |
| 650 | save_p = subs.add_parser( |
| 651 | "save", |
| 652 | help="Save the current working-tree state as a named shelf entry.", |
| 653 | description=( |
| 654 | "Snapshot the current working tree and save it as a named shelf entry.\n\n" |
| 655 | "After saving, the working tree is restored to HEAD so you can\n" |
| 656 | "switch context without committing half-done work.\n\n" |
| 657 | "Name rules\n" |
| 658 | "----------\n" |
| 659 | " Names must be unique within the repo. If omitted, a name is\n" |
| 660 | " generated from the current branch: ``dev/000``, ``dev/001``, etc.\n\n" |
| 661 | "Intent types\n" |
| 662 | "------------\n" |
| 663 | " interrupt agent was interrupted mid-task\n" |
| 664 | " checkpoint voluntary save before a risky operation (default)\n" |
| 665 | " handoff passing context to another agent or human\n" |
| 666 | " experiment exploring a hypothesis; may be discarded\n" |
| 667 | " draft work-in-progress, not ready for review\n\n" |
| 668 | "Agent quickstart\n" |
| 669 | "----------------\n" |
| 670 | " muse shelf save --json\n" |
| 671 | " muse shelf save auth-refactor --intent-type handoff \\\n" |
| 672 | " -m 'updating test assertions, 60% done'\n\n" |
| 673 | "Aliases\n" |
| 674 | "-------\n" |
| 675 | " ``muse shelf`` with no subcommand is identical to ``muse shelf save``.\n\n" |
| 676 | "Name format\n" |
| 677 | "-----------\n" |
| 678 | " Auto-generated names use ``branch/NNN`` (e.g. ``dev/000``, ``dev/001``).\n" |
| 679 | " Pass a custom name as the first positional argument to override.\n" |
| 680 | " Any name can be looked up by its exact string or by integer index.\n\n" |
| 681 | "JSON output schema\n" |
| 682 | "------------------\n" |
| 683 | ' {\n' |
| 684 | ' "status": "shelved | nothing_to_shelf",\n' |
| 685 | ' "id": "sha256:...",\n' |
| 686 | ' "name": "<name>",\n' |
| 687 | ' "snapshot_id": "sha256:...",\n' |
| 688 | ' "parent_commit": "sha256:...",\n' |
| 689 | ' "branch": "<branch>",\n' |
| 690 | ' "created_at": "<iso8601>",\n' |
| 691 | ' "created_by": {"handle": "<name>", "kind": "human | agent"},\n' |
| 692 | ' "intent_type": "<type>",\n' |
| 693 | ' "intent": "<text> | null",\n' |
| 694 | ' "resumable": true|false,\n' |
| 695 | ' "tags": [...],\n' |
| 696 | ' "files_count": N,\n' |
| 697 | ' "shelf_size": N\n' |
| 698 | ' }\n' |
| 699 | ), |
| 700 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 701 | ) |
| 702 | save_p.add_argument("name", nargs="?", default=None, |
| 703 | help="Name for this shelf entry (auto-generated if omitted).") |
| 704 | save_p.add_argument("-m", "--intent", default=None, |
| 705 | help="Free-text description of the work in progress.") |
| 706 | save_p.add_argument("--intent-type", default="checkpoint", dest="intent_type", |
| 707 | choices=sorted(_VALID_INTENT_TYPES), |
| 708 | help="Why this shelf was created (default: checkpoint).") |
| 709 | save_p.add_argument("--by", default="human", dest="created_by", |
| 710 | help="Agent ID or 'human' (default: human).") |
| 711 | save_p.add_argument("--resumable", action="store_true", default=False, |
| 712 | help="Mark this shelf as resumable by another agent.") |
| 713 | save_p.add_argument("--tag", action="append", default=[], dest="tags", |
| 714 | metavar="TAG", |
| 715 | help="Add a tag label (repeatable).") |
| 716 | save_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.") |
| 717 | save_p.set_defaults(func=run_save) |
| 718 | |
| 719 | # ── list ────────────────────────────────────────────────────────────────── |
| 720 | list_p = subs.add_parser( |
| 721 | "list", |
| 722 | help="List all shelf entries.", |
| 723 | description=( |
| 724 | "List all shelf entries, newest first.\n\n" |
| 725 | "An empty shelf is a valid result — exit code is always 0.\n\n" |
| 726 | "Filter flags\n" |
| 727 | "------------\n" |
| 728 | " --branch B only entries shelved from branch B\n" |
| 729 | " --resumable only entries marked resumable\n" |
| 730 | " --by AGENT_ID only entries created by this agent\n\n" |
| 731 | "Agent quickstart\n" |
| 732 | "----------------\n" |
| 733 | " muse shelf list --json\n" |
| 734 | " muse shelf list --resumable --json\n" |
| 735 | " muse shelf list --branch dev --json\n\n" |
| 736 | "JSON output schema\n" |
| 737 | "------------------\n" |
| 738 | ' [{\n' |
| 739 | ' "index": N,\n' |
| 740 | ' "id": "sha256:...",\n' |
| 741 | ' "name": "<name>",\n' |
| 742 | ' "snapshot_id": "sha256:...",\n' |
| 743 | ' "branch": "<branch>",\n' |
| 744 | ' "created_at": "<iso8601>",\n' |
| 745 | ' "created_by": {"handle": "<name>", "kind": "human | agent"},\n' |
| 746 | ' "intent_type": "<type>",\n' |
| 747 | ' "intent": "<text> | null",\n' |
| 748 | ' "resumable": true|false,\n' |
| 749 | ' "tags": [...],\n' |
| 750 | ' "files_count": N\n' |
| 751 | ' }, ...]\n' |
| 752 | ), |
| 753 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 754 | ) |
| 755 | list_p.add_argument("--branch", default=None, |
| 756 | help="Filter by branch.") |
| 757 | list_p.add_argument("--resumable", action="store_true", default=False, |
| 758 | help="Only show resumable entries.") |
| 759 | list_p.add_argument("--by", default=None, dest="created_by", |
| 760 | help="Filter by creator agent ID.") |
| 761 | list_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.") |
| 762 | list_p.set_defaults(func=run_list) |
| 763 | |
| 764 | # ── read ────────────────────────────────────────────────────────────────── |
| 765 | read_p = subs.add_parser( |
| 766 | "read", |
| 767 | help="Inspect a shelf entry without modifying the working tree.", |
| 768 | description=( |
| 769 | "Show the metadata and file list for a shelf entry.\n\n" |
| 770 | "Does not modify the working tree.\n\n" |
| 771 | "JSON output schema\n" |
| 772 | "------------------\n" |
| 773 | ' {\n' |
| 774 | ' "index": N,\n' |
| 775 | ' "id": "sha256:...",\n' |
| 776 | ' "name": "<name>",\n' |
| 777 | ' "snapshot_id": "sha256:...",\n' |
| 778 | ' "parent_commit": "sha256:...",\n' |
| 779 | ' "branch": "<branch>",\n' |
| 780 | ' "created_at": "<iso8601>",\n' |
| 781 | ' "created_by": {"handle": "<name>", "kind": "human | agent"},\n' |
| 782 | ' "intent_type": "<type>",\n' |
| 783 | ' "intent": "<text> | null",\n' |
| 784 | ' "resumable": true|false,\n' |
| 785 | ' "tags": [...],\n' |
| 786 | ' "files_count": N,\n' |
| 787 | ' "files": ["path/to/changed.py", ...],\n' |
| 788 | ' "deleted": ["path/to/gone.py", ...]\n' |
| 789 | ' }\n' |
| 790 | ), |
| 791 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 792 | ) |
| 793 | read_p.add_argument("entry", nargs="?", default=None, |
| 794 | help="Name or index of the entry to inspect (default: 0).") |
| 795 | read_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.") |
| 796 | read_p.set_defaults(func=run_read) |
| 797 | |
| 798 | # ── apply ───────────────────────────────────────────────────────────────── |
| 799 | apply_p = subs.add_parser( |
| 800 | "apply", |
| 801 | help="Restore a shelf entry to the working tree (keeps the entry).", |
| 802 | description=( |
| 803 | "Restore the shelf snapshot to the working tree without removing\n" |
| 804 | "the entry from the shelf. This is the default restore action.\n\n" |
| 805 | "Already-current detection\n" |
| 806 | "-------------------------\n" |
| 807 | "Files already at the shelf version (e.g. merged in since shelving)\n" |
| 808 | "are counted as 'already_current' rather than silently re-written.\n\n" |
| 809 | "JSON output schema\n" |
| 810 | "------------------\n" |
| 811 | ' {\n' |
| 812 | ' "status": "applied",\n' |
| 813 | ' "name": "<name>",\n' |
| 814 | ' "restored": N,\n' |
| 815 | ' "already_current": N,\n' |
| 816 | ' "deleted": N,\n' |
| 817 | ' "shelf_size": N\n' |
| 818 | ' }\n' |
| 819 | ), |
| 820 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 821 | ) |
| 822 | apply_p.add_argument("entry", nargs="?", default=None, |
| 823 | help="Name or index to apply (default: 0).") |
| 824 | apply_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.") |
| 825 | apply_p.set_defaults(func=run_apply) |
| 826 | |
| 827 | # ── pop ─────────────────────────────────────────────────────────────────── |
| 828 | pop_p = subs.add_parser( |
| 829 | "pop", |
| 830 | help="Restore a shelf entry and remove it from the shelf.", |
| 831 | description=( |
| 832 | "Restore the shelf snapshot to the working tree and remove the\n" |
| 833 | "entry from the shelf registry. Use this when you are done with\n" |
| 834 | "the shelf and want to continue the work in place.\n\n" |
| 835 | "JSON output schema\n" |
| 836 | "------------------\n" |
| 837 | ' {\n' |
| 838 | ' "status": "popped",\n' |
| 839 | ' "name": "<name>",\n' |
| 840 | ' "restored": N,\n' |
| 841 | ' "already_current": N,\n' |
| 842 | ' "deleted": N,\n' |
| 843 | ' "shelf_size_after": N\n' |
| 844 | ' }\n' |
| 845 | ), |
| 846 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 847 | ) |
| 848 | pop_p.add_argument("entry", nargs="?", default=None, |
| 849 | help="Name or index to pop (default: 0).") |
| 850 | pop_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.") |
| 851 | pop_p.set_defaults(func=run_pop) |
| 852 | |
| 853 | # ── drop ────────────────────────────────────────────────────────────────── |
| 854 | drop_p = subs.add_parser( |
| 855 | "drop", |
| 856 | help="Discard a shelf entry without applying it.", |
| 857 | description=( |
| 858 | "Remove a shelf entry from the registry without touching the\n" |
| 859 | "working tree.\n\n" |
| 860 | "JSON output schema\n" |
| 861 | "------------------\n" |
| 862 | ' {\n' |
| 863 | ' "status": "dropped",\n' |
| 864 | ' "name": "<name>",\n' |
| 865 | ' "id": "sha256:...",\n' |
| 866 | ' "shelf_size": N\n' |
| 867 | ' }\n' |
| 868 | ), |
| 869 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 870 | ) |
| 871 | drop_p.add_argument("entry", nargs="?", default=None, |
| 872 | help="Name or index to drop (default: 0).") |
| 873 | drop_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.") |
| 874 | drop_p.set_defaults(func=run_drop) |
| 875 | |
| 876 | # ── diff ────────────────────────────────────────────────────────────────── |
| 877 | diff_p = subs.add_parser( |
| 878 | "diff", |
| 879 | help="Show what applying a shelf entry would change.", |
| 880 | description=( |
| 881 | "Compare the shelf snapshot against the current HEAD to show what\n" |
| 882 | "would change if you ran 'muse shelf apply'.\n\n" |
| 883 | "Does not modify the working tree.\n\n" |
| 884 | "JSON output schema\n" |
| 885 | "------------------\n" |
| 886 | ' {\n' |
| 887 | ' "name": "<name>",\n' |
| 888 | ' "branch": "<branch>",\n' |
| 889 | ' "would_restore": ["path/to/file.py", ...],\n' |
| 890 | ' "already_current": ["path/to/same.py", ...],\n' |
| 891 | ' "would_delete": ["path/to/gone.py", ...]\n' |
| 892 | ' }\n' |
| 893 | ), |
| 894 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 895 | ) |
| 896 | diff_p.add_argument("entry", nargs="?", default=None, |
| 897 | help="Name or index to diff (default: 0).") |
| 898 | diff_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.") |
| 899 | diff_p.set_defaults(func=run_diff) |
| 900 | |
| 901 | parser.set_defaults(func=run_save) |
| 902 | |
| 903 | # --------------------------------------------------------------------------- |
| 904 | # Command implementations |
| 905 | # --------------------------------------------------------------------------- |
| 906 | |
| 907 | def run_save(args: argparse.Namespace) -> None: |
| 908 | """Save the current working-tree state as a named shelf entry. |
| 909 | |
| 910 | Writes all changed/added objects to the object store, records the full |
| 911 | working-tree manifest as a snapshot, then restores HEAD to the working |
| 912 | tree so you can switch context without a half-done commit. |
| 913 | |
| 914 | Agent quickstart:: |
| 915 | |
| 916 | muse shelf save --json |
| 917 | muse shelf save auth-refactor --intent-type handoff -m "updating tests, 60% done" --json |
| 918 | muse shelf save --intent-type checkpoint --resumable --json |
| 919 | |
| 920 | JSON fields:: |
| 921 | |
| 922 | status str "shelved" or "nothing_to_shelf" |
| 923 | id str|null Content-addressed shelf entry ID |
| 924 | name str|null Shelf entry name (auto-generated if omitted) |
| 925 | snapshot_id str|null sha256 of the working-tree snapshot |
| 926 | parent_commit str|null HEAD commit ID at shelf time |
| 927 | branch str Branch at shelf time |
| 928 | created_at str|null ISO 8601 creation timestamp |
| 929 | created_by dict {"handle": "<name>", "kind": "human|agent"} |
| 930 | intent_type str interrupt|checkpoint|handoff|experiment|draft |
| 931 | intent str|null Free-text description of work in progress |
| 932 | resumable bool True if another agent may continue this shelf |
| 933 | tags list[str] Arbitrary labels |
| 934 | files_count int Files differing from HEAD (0 when nothing_to_shelf) |
| 935 | shelf_size int Total entries in the shelf after this operation |
| 936 | |
| 937 | Exit codes:: |
| 938 | |
| 939 | 0 Success. |
| 940 | 1 Name collision or invalid --format. |
| 941 | 2 Not inside a Muse repository. |
| 942 | """ |
| 943 | elapsed = start_timer() |
| 944 | json_out: bool = args.json_out |
| 945 | name: str | None = getattr(args, "name", None) |
| 946 | intent: str | None = getattr(args, "intent", None) |
| 947 | intent_type: str = getattr(args, "intent_type", "checkpoint") |
| 948 | created_by: str = getattr(args, "created_by", "human") |
| 949 | resumable: bool = getattr(args, "resumable", False) |
| 950 | tags: list[str] = getattr(args, "tags", []) |
| 951 | |
| 952 | root = require_repo() |
| 953 | created_by_obj = _resolve_created_by(root, created_by) |
| 954 | branch = read_current_branch(root) |
| 955 | plugin = resolve_plugin(root) |
| 956 | manifest = plugin.snapshot(root)["files"] |
| 957 | head_manifest = get_head_snapshot_manifest(root, branch) or {} |
| 958 | |
| 959 | if manifest == head_manifest: |
| 960 | if json_out: |
| 961 | print(json.dumps(_ShelfSaveJson( |
| 962 | **make_envelope(elapsed), |
| 963 | status="nothing_to_shelf", |
| 964 | id=None, |
| 965 | name=None, |
| 966 | snapshot_id=None, |
| 967 | parent_commit=None, |
| 968 | branch=branch, |
| 969 | created_at=None, |
| 970 | created_by=created_by_obj, |
| 971 | intent_type=intent_type, |
| 972 | intent=intent, |
| 973 | resumable=resumable, |
| 974 | tags=tags, |
| 975 | files_count=0, |
| 976 | shelf_size=len(_load_shelf(root)), |
| 977 | ))) |
| 978 | else: |
| 979 | print("Nothing to shelf.") |
| 980 | return |
| 981 | |
| 982 | # Check for name collision before doing any work. |
| 983 | existing_entries = _load_shelf(root) |
| 984 | existing_names = {e["name"] for e in existing_entries} |
| 985 | if name is not None and name in existing_names: |
| 986 | print( |
| 987 | f"❌ A shelf entry named {sanitize_display(name)!r} already exists.\n" |
| 988 | " Use a different name, or drop the existing entry first with:\n" |
| 989 | f" muse shelf drop {sanitize_display(name)}", |
| 990 | file=sys.stderr, |
| 991 | ) |
| 992 | raise SystemExit(ExitCode.USER_ERROR) |
| 993 | |
| 994 | if name is None: |
| 995 | name = _generate_name(branch, existing_names) |
| 996 | |
| 997 | _ignore_patterns = load_ignore_patterns(root) |
| 998 | deleted: list[str] = [ |
| 999 | path for path in head_manifest |
| 1000 | if path not in manifest |
| 1001 | and not (is_ignored(path, _ignore_patterns) and (root / path).exists()) |
| 1002 | ] |
| 1003 | |
| 1004 | for rel_path, object_id in manifest.items(): |
| 1005 | if head_manifest.get(rel_path) != object_id: |
| 1006 | write_object_from_path(root, object_id, root / rel_path) |
| 1007 | |
| 1008 | snapshot_id = hash_snapshot(manifest, directories_from_manifest(manifest)) |
| 1009 | |
| 1010 | from muse.core.commits import resolve_commit_ref |
| 1011 | ref = resolve_commit_ref(root, branch, None) |
| 1012 | parent_commit = ref.commit_id if ref else "" |
| 1013 | |
| 1014 | created_at = now_utc_iso() |
| 1015 | entry_without_id = { |
| 1016 | "name": name, |
| 1017 | "snapshot": manifest, |
| 1018 | "deleted": deleted, |
| 1019 | "snapshot_id": snapshot_id, |
| 1020 | "parent_commit": parent_commit, |
| 1021 | "branch": branch, |
| 1022 | "created_at": created_at, |
| 1023 | "created_by": created_by_obj, |
| 1024 | "intent_type": intent_type, |
| 1025 | "intent": intent, |
| 1026 | "resumable": resumable, |
| 1027 | "tags": tags, |
| 1028 | "expires_at": None, |
| 1029 | "domain_state": {}, |
| 1030 | } |
| 1031 | shelf_id = _compute_shelf_id(entry_without_id) |
| 1032 | entry = ShelfEntry(id=shelf_id, **entry_without_id) # type: ignore[misc] |
| 1033 | |
| 1034 | _write_shelf_entry(root, entry) |
| 1035 | apply_manifest(root, manifest, head_manifest) |
| 1036 | |
| 1037 | files_count = sum( |
| 1038 | 1 for p, oid in manifest.items() if head_manifest.get(p) != oid |
| 1039 | ) + len(deleted) |
| 1040 | |
| 1041 | if json_out: |
| 1042 | print(json.dumps(_ShelfSaveJson( |
| 1043 | **make_envelope(elapsed), |
| 1044 | status="shelved", |
| 1045 | id=shelf_id, |
| 1046 | name=name, |
| 1047 | snapshot_id=snapshot_id, |
| 1048 | parent_commit=parent_commit, |
| 1049 | branch=branch, |
| 1050 | created_at=created_at, |
| 1051 | created_by=created_by_obj, |
| 1052 | intent_type=intent_type, |
| 1053 | intent=intent, |
| 1054 | resumable=resumable, |
| 1055 | tags=tags, |
| 1056 | files_count=files_count, |
| 1057 | shelf_size=len(existing_entries), |
| 1058 | ))) |
| 1059 | else: |
| 1060 | label = f" — {intent}" if intent else "" |
| 1061 | print(f"Shelved {sanitize_display(name)!r} ({files_count} file(s)){label}") |
| 1062 | |
| 1063 | def run_list(args: argparse.Namespace) -> None: |
| 1064 | """List shelf entries, newest first. |
| 1065 | |
| 1066 | Applies optional filters for branch, resumability, and creator. An empty |
| 1067 | shelf is a valid result — exit code is always 0 unless the format is invalid. |
| 1068 | |
| 1069 | Agent quickstart:: |
| 1070 | |
| 1071 | muse shelf list --json |
| 1072 | muse shelf list --resumable --json |
| 1073 | muse shelf list --branch dev --json |
| 1074 | |
| 1075 | JSON fields:: |
| 1076 | |
| 1077 | entries list Array of shelf entry objects (may be empty) |
| 1078 | entries[].index int 0-based position in the shelf |
| 1079 | entries[].id str Content-addressed shelf entry ID |
| 1080 | entries[].name str Human-readable name |
| 1081 | entries[].snapshot_id str sha256 of the snapshot |
| 1082 | entries[].branch str Branch at shelf time |
| 1083 | entries[].created_at str ISO 8601 creation timestamp |
| 1084 | entries[].created_by dict {"handle": "<name>", "kind": "human|agent"} |
| 1085 | entries[].intent_type str interrupt|checkpoint|handoff|experiment|draft |
| 1086 | entries[].intent str|null Free-text description |
| 1087 | entries[].resumable bool True if another agent may continue |
| 1088 | entries[].tags list[str] Arbitrary labels |
| 1089 | entries[].files_count int Files differing from HEAD |
| 1090 | |
| 1091 | Exit codes:: |
| 1092 | |
| 1093 | 0 Success (empty shelf is valid). |
| 1094 | 1 Invalid --format. |
| 1095 | 2 Not inside a Muse repository. |
| 1096 | """ |
| 1097 | elapsed = start_timer() |
| 1098 | json_out: bool = args.json_out |
| 1099 | filter_branch: str | None = getattr(args, "branch", None) |
| 1100 | filter_resumable: bool = getattr(args, "resumable", False) |
| 1101 | filter_by: str | None = getattr(args, "created_by", None) |
| 1102 | |
| 1103 | root = require_repo() |
| 1104 | branch = read_current_branch(root) |
| 1105 | head_manifest = get_head_snapshot_manifest(root, branch) or {} |
| 1106 | entries = _load_shelf(root) |
| 1107 | |
| 1108 | # Apply filters. |
| 1109 | filtered = [ |
| 1110 | (i, e) for i, e in enumerate(entries) |
| 1111 | if (filter_branch is None or e["branch"] == filter_branch) |
| 1112 | and (not filter_resumable or e["resumable"]) |
| 1113 | and (filter_by is None or e["created_by"]["handle"] == filter_by) |
| 1114 | ] |
| 1115 | |
| 1116 | if json_out: |
| 1117 | print(json.dumps(_ShelfListJson( |
| 1118 | **make_envelope(elapsed), |
| 1119 | entries=[_ShelfListEntryJson( |
| 1120 | index=i, |
| 1121 | id=e["id"], |
| 1122 | name=e["name"], |
| 1123 | snapshot_id=e["snapshot_id"], |
| 1124 | branch=e["branch"], |
| 1125 | created_at=e["created_at"], |
| 1126 | created_by=e["created_by"], |
| 1127 | intent_type=e["intent_type"], |
| 1128 | intent=e.get("intent"), |
| 1129 | resumable=e["resumable"], |
| 1130 | tags=e.get("tags", []), |
| 1131 | files_count=_entry_files_count(e, head_manifest), |
| 1132 | ) for i, e in filtered], |
| 1133 | ))) |
| 1134 | return |
| 1135 | |
| 1136 | if not filtered: |
| 1137 | print("No shelf entries.") |
| 1138 | return |
| 1139 | for i, e in filtered: |
| 1140 | label = f": {sanitize_display(e['intent'] or '')}" if e.get("intent") else "" |
| 1141 | intent_tag = f"[{e['intent_type']}]" if e.get("intent_type") else "" |
| 1142 | print( |
| 1143 | f"shelf/{i} {sanitize_display(e['name'])} " |
| 1144 | f"({sanitize_display(e['branch'])}) " |
| 1145 | f"{intent_tag}{label}" |
| 1146 | ) |
| 1147 | |
| 1148 | def run_read(args: argparse.Namespace) -> None: |
| 1149 | """Inspect a shelf entry without modifying the working tree. |
| 1150 | |
| 1151 | Shows metadata and the full file list for a shelf entry. Does not |
| 1152 | restore any files — use ``muse shelf apply`` or ``muse shelf pop`` for that. |
| 1153 | |
| 1154 | Agent quickstart:: |
| 1155 | |
| 1156 | muse shelf read --json |
| 1157 | muse shelf read 0 --json |
| 1158 | muse shelf read auth-refactor --json |
| 1159 | |
| 1160 | JSON fields:: |
| 1161 | |
| 1162 | index int 0-based position in the shelf |
| 1163 | id str Content-addressed shelf entry ID |
| 1164 | name str Human-readable name |
| 1165 | snapshot_id str sha256 of the snapshot |
| 1166 | parent_commit str HEAD commit ID at shelf time |
| 1167 | branch str Branch at shelf time |
| 1168 | created_at str ISO 8601 creation timestamp |
| 1169 | created_by dict {"handle": "<name>", "kind": "human|agent"} |
| 1170 | intent_type str interrupt|checkpoint|handoff|experiment|draft |
| 1171 | intent str|null Free-text description |
| 1172 | resumable bool True if another agent may continue |
| 1173 | tags list[str] Arbitrary labels |
| 1174 | files_count int Files differing from HEAD |
| 1175 | files list[str] Tracked paths in the snapshot |
| 1176 | deleted list[str] Paths removed before shelving |
| 1177 | |
| 1178 | Exit codes:: |
| 1179 | |
| 1180 | 0 Success. |
| 1181 | 1 Empty shelf or entry not found. |
| 1182 | 2 Not inside a Muse repository. |
| 1183 | """ |
| 1184 | elapsed = start_timer() |
| 1185 | json_out: bool = args.json_out |
| 1186 | raw: str | None = getattr(args, "entry", None) |
| 1187 | |
| 1188 | root = require_repo() |
| 1189 | entries = _load_shelf(root) |
| 1190 | if not entries: |
| 1191 | print("❌ No shelf entries.", file=sys.stderr) |
| 1192 | if json_out: |
| 1193 | print(json.dumps({"error": "no_shelf_entries", "shelf_size": 0})) |
| 1194 | raise SystemExit(ExitCode.USER_ERROR) |
| 1195 | |
| 1196 | try: |
| 1197 | idx, entry = _resolve_entry(entries, raw) |
| 1198 | except ValueError as exc: |
| 1199 | print(f"❌ {exc}", file=sys.stderr) |
| 1200 | raise SystemExit(ExitCode.USER_ERROR) |
| 1201 | |
| 1202 | branch = read_current_branch(root) |
| 1203 | head_manifest = get_head_snapshot_manifest(root, branch) or {} |
| 1204 | # Only paths that actually differ from HEAD — same set files_count counts. |
| 1205 | changed_files = sorted( |
| 1206 | p for p, oid in entry["snapshot"].items() |
| 1207 | if head_manifest.get(p) != oid |
| 1208 | ) |
| 1209 | deleted_files = sorted(entry["deleted"]) |
| 1210 | files_count = len(changed_files) + len(deleted_files) |
| 1211 | |
| 1212 | if json_out: |
| 1213 | print(json.dumps(_ShelfReadJson( |
| 1214 | **make_envelope(elapsed), |
| 1215 | index=idx, |
| 1216 | id=entry["id"], |
| 1217 | name=entry["name"], |
| 1218 | snapshot_id=entry["snapshot_id"], |
| 1219 | parent_commit=entry["parent_commit"], |
| 1220 | branch=entry["branch"], |
| 1221 | created_at=entry["created_at"], |
| 1222 | created_by=entry["created_by"], |
| 1223 | intent_type=entry["intent_type"], |
| 1224 | intent=entry.get("intent"), |
| 1225 | resumable=entry["resumable"], |
| 1226 | tags=entry.get("tags", []), |
| 1227 | files_count=files_count, |
| 1228 | files=changed_files, |
| 1229 | deleted=deleted_files, |
| 1230 | ))) |
| 1231 | else: |
| 1232 | label = f" — {sanitize_display(entry['intent'] or '')}" if entry.get("intent") else "" |
| 1233 | print(f"shelf/{idx} {sanitize_display(entry['name'])}{label}") |
| 1234 | cb = entry["created_by"] |
| 1235 | print(f" Branch: {sanitize_display(entry['branch'])}") |
| 1236 | print(f" Intent type: {entry['intent_type']}") |
| 1237 | print(f" Created by: {sanitize_display(cb['handle'])} ({cb['kind']})") |
| 1238 | print(f" Created at: {entry['created_at']}") |
| 1239 | print(f" Resumable: {entry['resumable']}") |
| 1240 | if entry.get("tags"): |
| 1241 | print(f" Tags: {', '.join(sanitize_display(t) for t in entry['tags'])}") |
| 1242 | print(f" Files ({files_count}):") |
| 1243 | for f in changed_files: |
| 1244 | print(f" M {sanitize_display(f)}") |
| 1245 | for f in deleted_files: |
| 1246 | print(f" D {sanitize_display(f)}") |
| 1247 | |
| 1248 | def run_apply(args: argparse.Namespace) -> None: |
| 1249 | """Restore a shelf entry to the working tree without removing it. |
| 1250 | |
| 1251 | Writes each file from the shelf snapshot that differs from HEAD. Files |
| 1252 | already at the shelf version are counted as ``already_current`` and not |
| 1253 | re-written. The entry remains in the shelf after this operation. |
| 1254 | |
| 1255 | Agent quickstart:: |
| 1256 | |
| 1257 | muse shelf apply --json |
| 1258 | muse shelf apply auth-refactor --json |
| 1259 | muse shelf apply 0 --json |
| 1260 | |
| 1261 | JSON fields:: |
| 1262 | |
| 1263 | status str Always "applied" |
| 1264 | name str Shelf entry name |
| 1265 | restored int Files written from the object store |
| 1266 | already_current int Files already at the shelf version (not re-written) |
| 1267 | deleted int Files removed from the working tree |
| 1268 | shelf_size int Total entries remaining in the shelf |
| 1269 | |
| 1270 | Exit codes:: |
| 1271 | |
| 1272 | 0 Success. |
| 1273 | 1 Empty shelf, entry not found, or invalid --format. |
| 1274 | 2 Not inside a Muse repository. |
| 1275 | 3 Object store integrity error (missing blobs). |
| 1276 | """ |
| 1277 | elapsed = start_timer() |
| 1278 | json_out: bool = args.json_out |
| 1279 | raw: str | None = getattr(args, "entry", None) |
| 1280 | |
| 1281 | root = require_repo() |
| 1282 | entries = _load_shelf(root) |
| 1283 | if not entries: |
| 1284 | print("❌ No shelf entries.", file=sys.stderr) |
| 1285 | if json_out: |
| 1286 | print(json.dumps({"error": "no_shelf_entries", "shelf_size": 0})) |
| 1287 | raise SystemExit(ExitCode.USER_ERROR) |
| 1288 | |
| 1289 | try: |
| 1290 | idx, entry = _resolve_entry(entries, raw) |
| 1291 | except ValueError as exc: |
| 1292 | print(f"❌ {exc}", file=sys.stderr) |
| 1293 | raise SystemExit(ExitCode.USER_ERROR) |
| 1294 | |
| 1295 | missing = _verify_snapshot_objects(root, entry["snapshot"]) |
| 1296 | if missing: |
| 1297 | print( |
| 1298 | f"❌ Shelf entry {sanitize_display(entry['name'])!r} is missing " |
| 1299 | f"{len(missing)} object(s) — aborting.", |
| 1300 | file=sys.stderr, |
| 1301 | ) |
| 1302 | for p in sorted(missing): |
| 1303 | print(f" missing: {sanitize_display(p)}", file=sys.stderr) |
| 1304 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 1305 | |
| 1306 | branch = read_current_branch(root) |
| 1307 | head_manifest = get_head_snapshot_manifest(root, branch) or {} |
| 1308 | counts = _apply_shelf_snapshot(root, entry, head_manifest) |
| 1309 | try: |
| 1310 | head_commit_id = get_head_commit_id(root, branch) |
| 1311 | if head_commit_id: |
| 1312 | append_reflog( |
| 1313 | root, branch, |
| 1314 | old_id=entry.get("parent_commit") or None, |
| 1315 | new_id=head_commit_id, |
| 1316 | author="user", |
| 1317 | operation=f"shelf-apply: restored {entry['name']!r}", |
| 1318 | ) |
| 1319 | except Exception as _rl_exc: |
| 1320 | logger.warning("⚠️ reflog: failed to record shelf-apply: %s", _rl_exc) |
| 1321 | |
| 1322 | if json_out: |
| 1323 | print(json.dumps(_ShelfApplyJson( |
| 1324 | **make_envelope(elapsed), |
| 1325 | status="applied", |
| 1326 | name=entry["name"], |
| 1327 | restored=counts["restored"], |
| 1328 | already_current=counts["already_current"], |
| 1329 | deleted=counts["deleted"], |
| 1330 | shelf_size=len(entries), |
| 1331 | ))) |
| 1332 | else: |
| 1333 | parts = [f"{counts['restored']} restored"] |
| 1334 | if counts["already_current"]: |
| 1335 | parts.append(f"{counts['already_current']} already current") |
| 1336 | if counts["deleted"]: |
| 1337 | parts.append(f"{counts['deleted']} deleted") |
| 1338 | print(f"Applied {sanitize_display(entry['name'])!r} ({', '.join(parts)})") |
| 1339 | |
| 1340 | def run_pop(args: argparse.Namespace) -> None: |
| 1341 | """Restore a shelf entry and remove it from the shelf. |
| 1342 | |
| 1343 | Restores the snapshot to the working tree and removes the entry from |
| 1344 | the shelf registry. Use this when you are ready to continue the shelved |
| 1345 | work in place and do not need to keep the checkpoint. |
| 1346 | |
| 1347 | Agent quickstart:: |
| 1348 | |
| 1349 | muse shelf pop --json |
| 1350 | muse shelf pop auth-refactor --json |
| 1351 | muse shelf pop 0 --json |
| 1352 | |
| 1353 | JSON fields:: |
| 1354 | |
| 1355 | status str Always "popped" |
| 1356 | name str Shelf entry name |
| 1357 | restored int Files written from the object store |
| 1358 | already_current int Files already at the shelf version (not re-written) |
| 1359 | deleted int Files removed from the working tree |
| 1360 | shelf_size_after int Total entries remaining after the pop |
| 1361 | |
| 1362 | Exit codes:: |
| 1363 | |
| 1364 | 0 Success. |
| 1365 | 1 Empty shelf, entry not found, or invalid --format. |
| 1366 | 2 Not inside a Muse repository. |
| 1367 | 3 Object store integrity error (missing blobs). |
| 1368 | """ |
| 1369 | elapsed = start_timer() |
| 1370 | json_out: bool = args.json_out |
| 1371 | raw: str | None = getattr(args, "entry", None) |
| 1372 | |
| 1373 | root = require_repo() |
| 1374 | entries = _load_shelf(root) |
| 1375 | if not entries: |
| 1376 | print("❌ No shelf entries.", file=sys.stderr) |
| 1377 | if json_out: |
| 1378 | print(json.dumps({"error": "no_shelf_entries", "shelf_size": 0})) |
| 1379 | raise SystemExit(ExitCode.USER_ERROR) |
| 1380 | |
| 1381 | try: |
| 1382 | idx, entry = _resolve_entry(entries, raw) |
| 1383 | except ValueError as exc: |
| 1384 | print(f"❌ {exc}", file=sys.stderr) |
| 1385 | raise SystemExit(ExitCode.USER_ERROR) |
| 1386 | |
| 1387 | missing = _verify_snapshot_objects(root, entry["snapshot"]) |
| 1388 | if missing: |
| 1389 | print( |
| 1390 | f"❌ Shelf entry {sanitize_display(entry['name'])!r} is missing " |
| 1391 | f"{len(missing)} object(s) — aborting to prevent data loss.", |
| 1392 | file=sys.stderr, |
| 1393 | ) |
| 1394 | for p in sorted(missing): |
| 1395 | print(f" missing: {sanitize_display(p)}", file=sys.stderr) |
| 1396 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 1397 | |
| 1398 | branch = read_current_branch(root) |
| 1399 | head_manifest = get_head_snapshot_manifest(root, branch) or {} |
| 1400 | |
| 1401 | _delete_shelf_entry(root, entry["id"]) |
| 1402 | counts = _apply_shelf_snapshot(root, entry, head_manifest) |
| 1403 | try: |
| 1404 | head_commit_id = get_head_commit_id(root, branch) |
| 1405 | if head_commit_id: |
| 1406 | append_reflog( |
| 1407 | root, branch, |
| 1408 | old_id=entry.get("parent_commit") or None, |
| 1409 | new_id=head_commit_id, |
| 1410 | author="user", |
| 1411 | operation=f"shelf-pop: restored {entry['name']!r}", |
| 1412 | ) |
| 1413 | except Exception as _rl_exc: |
| 1414 | logger.warning("⚠️ reflog: failed to record shelf-pop: %s", _rl_exc) |
| 1415 | remaining = _load_shelf(root) |
| 1416 | |
| 1417 | if json_out: |
| 1418 | print(json.dumps(_ShelfPopJson( |
| 1419 | **make_envelope(elapsed), |
| 1420 | status="popped", |
| 1421 | name=entry["name"], |
| 1422 | restored=counts["restored"], |
| 1423 | already_current=counts["already_current"], |
| 1424 | deleted=counts["deleted"], |
| 1425 | shelf_size_after=len(remaining), |
| 1426 | ))) |
| 1427 | else: |
| 1428 | parts = [f"{counts['restored']} restored"] |
| 1429 | if counts["already_current"]: |
| 1430 | parts.append(f"{counts['already_current']} already current") |
| 1431 | if counts["deleted"]: |
| 1432 | parts.append(f"{counts['deleted']} deleted") |
| 1433 | print(f"Popped {sanitize_display(entry['name'])!r} ({', '.join(parts)})") |
| 1434 | |
| 1435 | def run_drop(args: argparse.Namespace) -> None: |
| 1436 | """Discard a shelf entry without applying it. |
| 1437 | |
| 1438 | Removes the entry from the shelf registry without restoring any files. |
| 1439 | Use this to discard work-in-progress that is no longer needed. |
| 1440 | |
| 1441 | Agent quickstart:: |
| 1442 | |
| 1443 | muse shelf drop --json |
| 1444 | muse shelf drop auth-refactor --json |
| 1445 | muse shelf drop 0 --json |
| 1446 | |
| 1447 | JSON fields:: |
| 1448 | |
| 1449 | status str Always "dropped" |
| 1450 | name str Shelf entry name |
| 1451 | id str Content-addressed shelf entry ID |
| 1452 | shelf_size int Total entries remaining after the drop |
| 1453 | |
| 1454 | Exit codes:: |
| 1455 | |
| 1456 | 0 Success. |
| 1457 | 1 Empty shelf, entry not found, or invalid --format. |
| 1458 | 2 Not inside a Muse repository. |
| 1459 | """ |
| 1460 | elapsed = start_timer() |
| 1461 | json_out: bool = args.json_out |
| 1462 | raw: str | None = getattr(args, "entry", None) |
| 1463 | |
| 1464 | root = require_repo() |
| 1465 | entries = _load_shelf(root) |
| 1466 | if not entries: |
| 1467 | print("❌ No shelf entries.", file=sys.stderr) |
| 1468 | if json_out: |
| 1469 | print(json.dumps({"error": "no_shelf_entries", "shelf_size": 0})) |
| 1470 | raise SystemExit(ExitCode.USER_ERROR) |
| 1471 | |
| 1472 | try: |
| 1473 | idx, entry = _resolve_entry(entries, raw) |
| 1474 | except ValueError as exc: |
| 1475 | print(f"❌ {exc}", file=sys.stderr) |
| 1476 | raise SystemExit(ExitCode.USER_ERROR) |
| 1477 | |
| 1478 | _delete_shelf_entry(root, entry["id"]) |
| 1479 | remaining = _load_shelf(root) |
| 1480 | |
| 1481 | if json_out: |
| 1482 | print(json.dumps(_ShelfDropJson( |
| 1483 | **make_envelope(elapsed), |
| 1484 | status="dropped", |
| 1485 | name=entry["name"], |
| 1486 | id=entry["id"], |
| 1487 | shelf_size=len(remaining), |
| 1488 | ))) |
| 1489 | else: |
| 1490 | print(f"Dropped {sanitize_display(entry['name'])!r}") |
| 1491 | |
| 1492 | def run_diff(args: argparse.Namespace) -> None: |
| 1493 | """Show what applying a shelf entry would change. |
| 1494 | |
| 1495 | Compares the shelf snapshot against the current HEAD. Does not modify |
| 1496 | the working tree — purely informational, safe to call at any time. |
| 1497 | |
| 1498 | Agent quickstart:: |
| 1499 | |
| 1500 | muse shelf diff --json |
| 1501 | muse shelf diff auth-refactor --json |
| 1502 | muse shelf diff 0 --json |
| 1503 | |
| 1504 | JSON fields:: |
| 1505 | |
| 1506 | name str Shelf entry name |
| 1507 | branch str Branch at shelf time |
| 1508 | would_restore list[str] Paths that differ from HEAD (would be written) |
| 1509 | already_current list[str] Paths already at the shelf version (no-op) |
| 1510 | would_delete list[str] Paths that would be removed from the working tree |
| 1511 | |
| 1512 | Exit codes:: |
| 1513 | |
| 1514 | 0 Success. |
| 1515 | 1 Empty shelf, entry not found, or invalid --format. |
| 1516 | 2 Not inside a Muse repository. |
| 1517 | """ |
| 1518 | elapsed = start_timer() |
| 1519 | json_out: bool = args.json_out |
| 1520 | raw: str | None = getattr(args, "entry", None) |
| 1521 | |
| 1522 | root = require_repo() |
| 1523 | entries = _load_shelf(root) |
| 1524 | if not entries: |
| 1525 | print("❌ No shelf entries.", file=sys.stderr) |
| 1526 | if json_out: |
| 1527 | print(json.dumps({"error": "no_shelf_entries", "shelf_size": 0})) |
| 1528 | raise SystemExit(ExitCode.USER_ERROR) |
| 1529 | |
| 1530 | try: |
| 1531 | idx, entry = _resolve_entry(entries, raw) |
| 1532 | except ValueError as exc: |
| 1533 | print(f"❌ {exc}", file=sys.stderr) |
| 1534 | raise SystemExit(ExitCode.USER_ERROR) |
| 1535 | |
| 1536 | branch = read_current_branch(root) |
| 1537 | head_manifest = get_head_snapshot_manifest(root, branch) or {} |
| 1538 | |
| 1539 | would_restore = sorted( |
| 1540 | p for p, oid in entry["snapshot"].items() |
| 1541 | if head_manifest.get(p) != oid |
| 1542 | ) |
| 1543 | already_current = sorted( |
| 1544 | p for p, oid in entry["snapshot"].items() |
| 1545 | if head_manifest.get(p) == oid |
| 1546 | ) |
| 1547 | would_delete = sorted(entry["deleted"]) |
| 1548 | |
| 1549 | if json_out: |
| 1550 | print(json.dumps(_ShelfDiffJson( |
| 1551 | **make_envelope(elapsed), |
| 1552 | name=entry["name"], |
| 1553 | branch=entry["branch"], |
| 1554 | would_restore=would_restore, |
| 1555 | already_current=already_current, |
| 1556 | would_delete=would_delete, |
| 1557 | ))) |
| 1558 | else: |
| 1559 | label = f" — {sanitize_display(entry['intent'] or '')}" if entry.get("intent") else "" |
| 1560 | print(f"shelf/{idx} {sanitize_display(entry['name'])}{label}") |
| 1561 | for f in would_restore: |
| 1562 | print(f" M {sanitize_display(f)} (would restore)") |
| 1563 | for f in would_delete: |
| 1564 | print(f" D {sanitize_display(f)} (would delete)") |
| 1565 | for f in already_current: |
| 1566 | print(f" = {sanitize_display(f)} (already current)") |
File History
1 commit
sha256:502e812e12319e30302d07d165425ceeb21687ad30e8af3a439b1f6de36c64d6
fix: update stale tag tests to use label for free-form anno…
Sonnet 4.6
19 days ago