gabriel / muse public
shelf.py python
1,566 lines 60.1 KB
Raw
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