"""``muse stash`` — temporarily shelve uncommitted changes. Saves the current working tree diff to ``.muse/stash.json`` and restores the HEAD snapshot to the working tree, so you can work on something else without committing half-done work. Usage:: muse stash [-m MSG] — save current changes and restore HEAD muse stash pop [N] — restore stash entry N (default: 0) and remove it muse stash apply [N] — restore stash entry N without removing it muse stash show [N] — show which files are in stash entry N muse stash list — list all stash entries muse stash drop [N] — discard stash entry N (default: 0) JSON output (``--format json`` or ``--json``) schema for ``stash push``:: { "status": "stashed | nothing_to_stash", "snapshot_id": " | null", "branch": "", "stashed_at": "", "message": "", "files_count": , "stash_size": } Exit codes:: 0 — success 1 — no stash entries, invalid format, invalid index 3 — object store integrity error (missing object on pop/apply) """ from __future__ import annotations import argparse import datetime import json import logging import os import pathlib import sys import tempfile from typing import TypedDict from muse.core._types import Manifest from muse.core.errors import ExitCode from muse.core.object_store import read_object, write_object_from_path from muse.core.repo import read_repo_id, require_repo from muse.core.ignore import is_ignored from muse.core.snapshot import ( compute_snapshot_id, directories_from_manifest, load_ignore_patterns, ) from muse.core.store import get_head_snapshot_manifest, read_current_branch from muse.core.validation import assert_not_symlink, sanitize_display from muse.core.workdir import apply_manifest from muse.plugins.registry import resolve_plugin _STASH_MAX_BYTES = 64 * 1024 * 1024 # 64 MiB guard against huge stash files _STASH_FILE = ".muse/stash.json" logger = logging.getLogger(__name__) class StashEntry(TypedDict): """A single entry in the stash stack. ``delta`` contains only files that differ from HEAD at stash time — modified files (new object ID) and newly-added files. ``deleted`` lists paths that existed in HEAD but were removed from the working tree before the stash was created. Storing only the delta means ``stash pop`` touches only the files that were actually changed, leaving files that were committed after the stash was created at their newer committed versions. """ snapshot_id: str delta: Manifest # path → object_id for modified/added files deleted: list[str] # paths removed from working tree relative to HEAD branch: str stashed_at: str message: str | None def _load_stash(root: pathlib.Path) -> list[StashEntry]: """Load the stash stack from disk, returning an empty list on any error. Guards against symlink attacks, oversized files, and malformed JSON. """ stash_path = root / _STASH_FILE if not stash_path.exists(): return [] # Reject symlinks before any read to prevent TOCTOU path-traversal attacks. try: assert_not_symlink(stash_path) except (ValueError, OSError): logger.warning("⚠️ stash.json is a symlink or inaccessible — ignoring") return [] stat = stash_path.stat() if stat.st_size > _STASH_MAX_BYTES: logger.warning("⚠️ stash.json exceeds size limit (%d bytes) — ignoring", stat.st_size) return [] try: raw = json.loads(stash_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): logger.warning("⚠️ stash.json is unreadable or malformed — ignoring") return [] if not isinstance(raw, list): logger.warning("⚠️ stash.json has unexpected structure — ignoring") return [] entries: list[StashEntry] = [] for item in raw: if not isinstance(item, dict): continue delta = item.get("delta") if not isinstance(delta, dict): continue safe_delta: Manifest = { k: v for k, v in delta.items() if isinstance(k, str) and isinstance(v, str) } raw_deleted = item.get("deleted", []) safe_deleted: list[str] = [ p for p in raw_deleted if isinstance(p, str) ] if isinstance(raw_deleted, list) else [] entries.append(StashEntry( snapshot_id=str(item.get("snapshot_id", "")), delta=safe_delta, deleted=safe_deleted, branch=str(item.get("branch", "")), stashed_at=str(item.get("stashed_at", "")), message=str(item["message"]) if item.get("message") else None, )) return entries def _save_stash(root: pathlib.Path, stash: list[StashEntry]) -> None: """Write the stash stack atomically with an fsync barrier. Uses temp-file + rename to survive crashes, and flushes to the journal before the rename so the new data is durable on power loss. """ target = root / _STASH_FILE payload = json.dumps(stash, indent=2, ensure_ascii=False) fd, tmp_path_str = tempfile.mkstemp(dir=target.parent, prefix=".stash_tmp_", suffix=".json") tmp_path = pathlib.Path(tmp_path_str) try: with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write(payload) fh.flush() # Durability barrier: flush kernel buffers before the rename so the # new stash data survives a crash between fsync and os.replace. try: import fcntl as _fcntl _fcntl.fcntl(fh.fileno(), 85) # F_BARRIERFSYNC on macOS except (AttributeError, OSError): os.fsync(fh.fileno()) os.replace(tmp_path_str, target) except Exception: tmp_path.unlink(missing_ok=True) raise def _resolve_index(entries: list[StashEntry], raw: str | None, *, default: int = 0) -> int: """Parse an optional stash index argument, validate bounds, return the int. Raises ``ValueError`` with a human-readable message on invalid input. """ if raw is None: idx = default else: try: idx = int(raw) except ValueError: raise ValueError(f"Invalid stash index {sanitize_display(raw)!r} — must be an integer.") if idx < 0 or idx >= len(entries): raise ValueError( f"Stash index {idx} is out of range " f"(0–{len(entries) - 1 if entries else 0})." ) return idx def _verify_manifest_objects(root: pathlib.Path, delta: Manifest) -> list[str]: """Return paths in *delta* whose object IDs are absent from the object store.""" missing: list[str] = [] for rel_path, obj_id in delta.items(): if read_object(root, obj_id) is None: missing.append(rel_path) return missing def _apply_stash_delta( root: pathlib.Path, delta: Manifest, deleted: list[str], ) -> None: """Restore only the files that were changed when the stash was created. Writes each file in *delta* from the object store into the working tree, then removes each path in *deleted*. Files that were unchanged at stash time are never touched, so any commits made to those files after the stash was created are preserved. """ from muse.core.object_store import read_object as _read_object for rel_path, obj_id in delta.items(): data = _read_object(root, obj_id) if data is None: logger.warning("⚠️ stash: object missing for %s — skipping", rel_path) continue dest = root / rel_path dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(data) for rel_path in deleted: target = root / rel_path try: target.unlink() except FileNotFoundError: pass # already gone — idempotent def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the ``muse stash`` subcommand tree and all its flags.""" parser = subparsers.add_parser( "stash", help="Temporarily shelve uncommitted changes.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "-m", "--message", default=None, help="Annotate this stash entry with a description.", ) parser.add_argument( "--include-untracked", "-u", action="store_true", dest="include_untracked", help=( "Include untracked files in the stash. " "In muse, all files are tracked by the domain plugin snapshot, so this flag " "is accepted for compatibility but has no additional effect." ), ) parser.add_argument("--format", "-f", default="text", dest="fmt", help="Output format: text or json.") parser.add_argument("--json", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.") subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") apply_p = subs.add_parser( "apply", help="Restore a stash entry without removing it.", description=( "Restore a stash entry to the working tree without removing it from the stack.\n\n" "Unlike ``pop``, the stash entry remains after apply, so you can apply the\n" "same changes to multiple branches or inspect the result before committing.\n\n" "Index rules\n" "-----------\n" " stash@{0} — most recently stashed (default)\n" " stash@{N} — Nth entry (0-based, error if out of range)\n\n" "Safety\n" "------\n" "All stash objects are verified against the object store before the working\n" "tree is modified. If any object is missing, apply aborts with exit code 3.\n\n" "Agent quickstart\n" "----------------\n" " muse stash apply --json # apply stash@{0}, machine-readable\n" " muse stash apply 2 --json # apply stash@{2} without removing it\n\n" "JSON output schema\n" "------------------\n" ' {"status": "applied", "snapshot_id": "",\n' ' "branch": "", "stashed_at": "",\n' ' "message": " | null", "files_count": N,\n' ' "stash_size": N}\n\n' "Exit codes\n" "----------\n" " 0 — success\n" " 1 — no stash entries, invalid format, out-of-range or non-integer index\n" " 2 — not inside a Muse repository\n" " 3 — missing objects in object store (stash is corrupt)\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) apply_p.add_argument("index", nargs="?", default=None, help="Index of the stash entry to apply (default: 0).") apply_p.add_argument("--format", "-f", default="text", dest="fmt", help="Output format: text or json.") apply_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.") apply_p.set_defaults(func=run_apply) drop_p = subs.add_parser( "drop", help="Discard a stash entry.", description=( "Discard a stash entry without applying it to the working tree.\n\n" "Index rules\n" "-----------\n" " stash@{0} — most recently stashed (default)\n" " stash@{N} — Nth entry (0-based, error if out of range)\n\n" "Agent quickstart\n" "----------------\n" " muse stash drop --json # drop stash@{0}, machine-readable\n" " muse stash drop 2 --json # drop stash@{2}\n\n" "JSON output schema\n" "------------------\n" ' {"status": "dropped", "index": N, "snapshot_id": "",\n' ' "branch": "", "stashed_at": "",\n' ' "message": " | null", "stash_size": N}\n\n' "Exit codes\n" "----------\n" " 0 — success\n" " 1 — no stash entries, invalid format, out-of-range or non-integer index\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) drop_p.add_argument("index", nargs="?", default=None, help="Index of the stash entry to drop (default: 0).") drop_p.add_argument("--format", "-f", default="text", dest="fmt", help="Output format: text or json.") drop_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.") drop_p.set_defaults(func=run_drop) list_p = subs.add_parser( "list", help="List all stash entries.", description=( "List all stash entries in LIFO order (most-recently stashed first).\n\n" "An empty stash is a valid result — exit code is always 0.\n\n" "Agent quickstart\n" "----------------\n" " muse stash list --json # full machine-readable list\n" " muse stash list --json | jq 'length' # count stash entries\n" " muse stash list --json | jq '.[0].message' # annotation of top entry\n\n" "JSON output schema\n" "------------------\n" " Array of objects, one per stash entry:\n" ' [{"index": N, "snapshot_id": "",\n' ' "branch": "", "stashed_at": "",\n' ' "message": " | null", "files_count": N}, ...]\n\n' "Exit codes\n" "----------\n" " 0 — success (including empty stash)\n" " 1 — invalid format\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) list_p.add_argument("--format", "-f", default="text", dest="fmt", help="Output format: text or json.") list_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.") list_p.set_defaults(func=run_list) pop_p = subs.add_parser( "pop", help="Restore a stash entry and remove it from the stack.", description=( "Restore a stash entry to the working tree and remove it from the stash stack.\n\n" "Index rules\n" "-----------\n" " stash@{0} — most recently stashed (default)\n" " stash@{1} — second-most-recently stashed\n" " stash@{N} — Nth entry (0-based, error if out of range)\n\n" "Safety\n" "------\n" "All stash objects are verified against the object store before the working\n" "tree is modified. If any object is missing, pop aborts with exit code 3\n" "to prevent a corrupt workdir.\n\n" "Agent quickstart\n" "----------------\n" " muse stash pop --json # pop stash@{0}, machine-readable result\n" " muse stash pop 2 --json # pop stash@{2}\n\n" "JSON output schema\n" "------------------\n" ' {"status": "popped", "snapshot_id": "",\n' ' "branch": "", "stashed_at": "",\n' ' "message": " | null", "files_count": N,\n' ' "stash_size_after": N}\n\n' "Exit codes\n" "----------\n" " 0 — success\n" " 1 — no stash entries, invalid format, out-of-range or non-integer index\n" " 2 — not inside a Muse repository\n" " 3 — missing objects in object store (stash is corrupt)\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) pop_p.add_argument("index", nargs="?", default=None, help="Index of the stash entry to pop (default: 0).") pop_p.add_argument("--format", "-f", default="text", dest="fmt", help="Output format: text or json.") pop_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.") pop_p.set_defaults(func=run_pop) show_p = subs.add_parser( "show", help="Show the files in a stash entry.", description=( "Show which files are contained in a stash entry without modifying the working tree.\n\n" "Index rules\n" "-----------\n" " stash@{0} — most recently stashed (default)\n" " stash@{N} — Nth entry (0-based, error if out of range)\n\n" "Agent quickstart\n" "----------------\n" " muse stash show --json # inspect stash@{0}\n" " muse stash show 2 --json # inspect stash@{2}\n" " muse stash show --json | jq '.files[]' # list stashed paths\n\n" "JSON output schema\n" "------------------\n" ' {"index": N, "snapshot_id": "",\n' ' "branch": "", "stashed_at": "",\n' ' "message": " | null",\n' ' "files_count": N, "files": ["path/to/file.py", ...]}\n\n' "Exit codes\n" "----------\n" " 0 — success\n" " 1 — no stash entries, invalid format, out-of-range or non-integer index\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) show_p.add_argument("index", nargs="?", default=None, help="Index of the stash entry to show (default: 0).") show_p.add_argument("--format", "-f", default="text", dest="fmt", help="Output format: text or json.") show_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.") show_p.set_defaults(func=run_show) parser.set_defaults(func=run) # ── Programmatic API (used by checkout --autostash) ─────────────────────────── def _stash_push_programmatic( root: pathlib.Path, message: str | None = None, ) -> "StashEntry | None": """Save current working-tree changes to the stash without CLI output. Returns the new :class:`StashEntry` if changes were stashed, or ``None`` when the working tree is already clean (nothing to stash). This is the same logic as :func:`run` but without argparse, format handling, or prints — safe to call from other commands such as ``muse checkout --autostash``. Args: root: Repository root (the directory that contains ``.muse/``). message: Optional annotation stored alongside the entry. Returns: The stash entry that was pushed, or ``None`` if the tree was clean. """ repo_id = read_repo_id(root) branch = read_current_branch(root) plugin = resolve_plugin(root) manifest = plugin.snapshot(root)["files"] head_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {} if manifest == head_manifest: return None delta: Manifest = { path: oid for path, oid in manifest.items() if head_manifest.get(path) != oid } _ignore_patterns = load_ignore_patterns(root) deleted: list[str] = [ path for path in head_manifest if path not in manifest and not (is_ignored(path, _ignore_patterns) and (root / path).exists()) ] snapshot_id = compute_snapshot_id(manifest, directories_from_manifest(manifest)) for rel_path, object_id in delta.items(): write_object_from_path(root, object_id, root / rel_path) stashed_at = datetime.datetime.now(datetime.timezone.utc).isoformat() entry = StashEntry( snapshot_id=snapshot_id, delta=delta, deleted=deleted, branch=branch, stashed_at=stashed_at, message=message, ) entries = _load_stash(root) entries.insert(0, entry) _save_stash(root, entries) apply_manifest(root, head_manifest) logger.debug("autostash: stashed %d file(s) as stash@{0}", len(delta) + len(deleted)) return entry def _stash_pop_programmatic(root: pathlib.Path, index: int = 0) -> "StashEntry": """Restore a stash entry and remove it from the stack without CLI output. Mirrors the logic in :func:`run_pop` but raises exceptions instead of calling :func:`sys.exit`, making it safe to call from other commands such as ``muse checkout --autostash``. Args: root: Repository root (the directory that contains ``.muse/``). index: Zero-based index into the stash stack (default ``0`` = most recent entry). Returns: The :class:`StashEntry` that was applied and removed. Raises: ValueError: If the stash is empty or *index* is out of range. RuntimeError: If objects are missing from the object store. """ entries = _load_stash(root) if not entries: raise ValueError("No stash entries to pop.") if index < 0 or index >= len(entries): raise ValueError( f"Stash index {index} is out of range (0–{len(entries) - 1})." ) entry = entries[index] try: missing = _verify_manifest_objects(root, entry["delta"]) except OSError as exc: raise RuntimeError( f"Stash entry {index} failed object-store integrity check: {exc}" ) from exc if missing: raise RuntimeError( f"Stash entry {index} is missing {len(missing)} object(s) from " f"the object store: {', '.join(sorted(missing))}" ) entries.pop(index) _save_stash(root, entries) _apply_stash_delta(root, entry["delta"], entry["deleted"]) logger.debug("autostash: restored stash@{%d} (%d file(s))", index, len(entry["delta"]) + len(entry["deleted"])) return entry def run(args: argparse.Namespace) -> None: """Save current working-tree changes and restore HEAD. Serialises a manifest of current tracked files into ``.muse/stash.json`` and writes each file's content into the object store. Then restores the HEAD snapshot so the working tree is clean. All error messages are written to **stderr**; stdout is reserved for structured output only. Agents should pass ``--format json`` for a stable schema:: { "status": "stashed | nothing_to_stash", "snapshot_id": " | null", "branch": "", "stashed_at": "", "message": " | null", "files_count": , "stash_size": } Exit codes:: 0 — success (stashed or nothing_to_stash) 1 — invalid format """ fmt: str = args.fmt message: str | None = args.message if fmt not in ("text", "json"): print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() repo_id = read_repo_id(root) branch = read_current_branch(root) plugin = resolve_plugin(root) manifest = plugin.snapshot(root)["files"] head_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {} if manifest == head_manifest: if fmt == "json": print(json.dumps({ "status": "nothing_to_stash", "snapshot_id": None, "branch": branch, "stashed_at": None, "message": None, "files_count": 0, "stash_size": len(_load_stash(root)), })) else: print("Nothing to stash.") return # Compute delta: only files that differ from HEAD (modified or newly added). delta: Manifest = { path: oid for path, oid in manifest.items() if head_manifest.get(path) != oid } # Deleted: in HEAD but genuinely absent from the working tree. # Files that are now in .museignore and still present on disk are excluded # — they are not deleted, just moved out of tracking. Recording them as # deleted would cause stash pop to unlink them from disk. _ignore_patterns = load_ignore_patterns(root) deleted: list[str] = [ path for path in head_manifest if path not in manifest and not (is_ignored(path, _ignore_patterns) and (root / path).exists()) ] snapshot_id = compute_snapshot_id(manifest, directories_from_manifest(manifest)) # Only write objects for changed/added files — unchanged files are already # in the object store from the HEAD commit. for rel_path, object_id in delta.items(): write_object_from_path(root, object_id, root / rel_path) stashed_at = datetime.datetime.now(datetime.timezone.utc).isoformat() stash_entry = StashEntry( snapshot_id=snapshot_id, delta=delta, deleted=deleted, branch=branch, stashed_at=stashed_at, message=message, ) entries = _load_stash(root) entries.insert(0, stash_entry) _save_stash(root, entries) apply_manifest(root, head_manifest) if fmt == "json": print(json.dumps({ "status": "stashed", "snapshot_id": snapshot_id, "branch": branch, "stashed_at": stashed_at, "message": message, "files_count": len(delta) + len(deleted), "stash_size": len(entries), })) else: label = f" ({message})" if message else "" print(f"Saved working directory (stash@{{0}}){label}") def run_pop(args: argparse.Namespace) -> None: """Restore a stash entry and remove it from the stack. Validates that all objects in the stash manifest exist in the object store before modifying the working tree. Exits with ``INTERNAL_ERROR`` (3) if any objects are missing to prevent a corrupt workdir. JSON schema:: { "status": "popped", "snapshot_id": "", "branch": "", "stashed_at": "", "message": " | null", "files_count": , "stash_size_after": } Exit codes:: 0 — success 1 — no stash entries, invalid format, out-of-range index 2 — not inside a Muse repository 3 — missing objects in object store """ fmt: str = args.fmt raw_index: str | None = getattr(args, "index", None) if fmt not in ("text", "json"): print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() entries = _load_stash(root) if not entries: print("❌ No stash entries.", file=sys.stderr) if fmt == "json": print(json.dumps({"error": "no_stash_entries", "stash_size": 0})) raise SystemExit(ExitCode.USER_ERROR) try: idx = _resolve_index(entries, raw_index) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) entry = entries[idx] missing = _verify_manifest_objects(root, entry["delta"]) if missing: print( f"❌ Stash entry {idx} is missing {len(missing)} object(s) from the object store — " "aborting to prevent data loss.", file=sys.stderr, ) for p in sorted(missing): print(f" missing: {sanitize_display(p)}", file=sys.stderr) raise SystemExit(ExitCode.INTERNAL_ERROR) entries.pop(idx) _save_stash(root, entries) _apply_stash_delta(root, entry["delta"], entry["deleted"]) files_count = len(entry["delta"]) + len(entry["deleted"]) if fmt == "json": print(json.dumps({ "status": "popped", "snapshot_id": entry["snapshot_id"], "branch": entry["branch"], "stashed_at": entry["stashed_at"], "message": entry.get("message"), "files_count": files_count, "stash_size_after": len(entries), })) else: label = f" ({sanitize_display(entry['message'] or '')})" if entry.get("message") else "" print( f"Restored stash@{{{idx}}} " f"(branch: {sanitize_display(entry['branch'])}){label}" ) def run_apply(args: argparse.Namespace) -> None: """Restore a stash entry without removing it from the stack. Useful for applying the same stash to multiple branches. JSON schema:: { "status": "applied", "snapshot_id": "", "branch": "", "stashed_at": "", "message": " | null", "files_count": , "stash_size": } Exit codes:: 0 — success 1 — no stash entries, invalid format, out-of-range index 2 — not inside a Muse repository 3 — missing objects in object store """ fmt: str = args.fmt raw_index: str | None = getattr(args, "index", None) if fmt not in ("text", "json"): print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() entries = _load_stash(root) if not entries: print("❌ No stash entries.", file=sys.stderr) if fmt == "json": print(json.dumps({"error": "no_stash_entries", "stash_size": 0})) raise SystemExit(ExitCode.USER_ERROR) try: idx = _resolve_index(entries, raw_index) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) entry = entries[idx] missing = _verify_manifest_objects(root, entry["delta"]) if missing: print( f"❌ Stash entry {idx} is missing {len(missing)} object(s) from the object store — " "aborting to prevent data loss.", file=sys.stderr, ) for p in sorted(missing): print(f" missing: {sanitize_display(p)}", file=sys.stderr) raise SystemExit(ExitCode.INTERNAL_ERROR) _apply_stash_delta(root, entry["delta"], entry["deleted"]) files_count = len(entry["delta"]) + len(entry["deleted"]) if fmt == "json": print(json.dumps({ "status": "applied", "snapshot_id": entry["snapshot_id"], "branch": entry["branch"], "stashed_at": entry["stashed_at"], "message": entry.get("message"), "files_count": files_count, "stash_size": len(entries), })) else: label = f" ({sanitize_display(entry['message'] or '')})" if entry.get("message") else "" print( f"Applied stash@{{{idx}}} " f"(branch: {sanitize_display(entry['branch'])}){label} — stash entry preserved" ) def run_show(args: argparse.Namespace) -> None: """Show which files are contained in a stash entry. JSON schema:: { "index": , "snapshot_id": "", "branch": "", "stashed_at": "", "message": " | null", "files_count": , "files": ["path/to/file.py", ...] } Exit codes:: 0 — success 1 — no stash entries, invalid format, out-of-range index 2 — not inside a Muse repository """ fmt: str = args.fmt raw_index: str | None = getattr(args, "index", None) if fmt not in ("text", "json"): print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() entries = _load_stash(root) if not entries: print("❌ No stash entries.", file=sys.stderr) if fmt == "json": print(json.dumps({"error": "no_stash_entries", "stash_size": 0})) raise SystemExit(ExitCode.USER_ERROR) try: idx = _resolve_index(entries, raw_index) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) entry = entries[idx] modified = sorted(entry["delta"].keys()) deleted = sorted(entry["deleted"]) files_count = len(modified) + len(deleted) if fmt == "json": print(json.dumps({ "index": idx, "snapshot_id": entry["snapshot_id"], "branch": entry["branch"], "stashed_at": entry["stashed_at"], "message": entry.get("message"), "files_count": files_count, "files": modified, "deleted": deleted, })) else: label = f" — {sanitize_display(entry['message'] or '')}" if entry.get("message") else "" print(f"stash@{{{idx}}}: WIP on {sanitize_display(entry['branch'])}{label}") for f in modified: print(f" M {sanitize_display(f)}") for f in deleted: print(f" D {sanitize_display(f)}") def run_list(args: argparse.Namespace) -> None: """List all stash entries. JSON schema (array):: [ { "index": , "snapshot_id": "", "branch": "", "stashed_at": "", "message": " | null", "files_count": }, ... ] Exit codes:: 0 — always (empty list is a valid result) 1 — invalid format 2 — not inside a Muse repository """ fmt: str = args.fmt if fmt not in ("text", "json"): print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() entries = _load_stash(root) if fmt == "json": print(json.dumps([{ "index": i, "snapshot_id": e["snapshot_id"], "branch": e["branch"], "stashed_at": e["stashed_at"], "message": e.get("message"), "files_count": len(e["delta"]) + len(e["deleted"]), } for i, e in enumerate(entries)])) return if not entries: print("No stash entries.") return for i, entry in enumerate(entries): label = f": {sanitize_display(entry['message'] or '')}" if entry.get("message") else "" print( f"stash@{{{i}}}: WIP on {sanitize_display(entry['branch'])} " f"— {sanitize_display(entry['stashed_at'])}{label}" ) def run_drop(args: argparse.Namespace) -> None: """Discard a stash entry without applying it. JSON schema:: { "status": "dropped", "index": , "snapshot_id": "", "branch": "", "stashed_at": "", "message": " | null", "stash_size": } Exit codes:: 0 — success 1 — no stash entries, invalid format, out-of-range index 2 — not inside a Muse repository """ fmt: str = args.fmt raw_index: str | None = getattr(args, "index", None) if fmt not in ("text", "json"): print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() entries = _load_stash(root) if not entries: print("❌ No stash entries.", file=sys.stderr) if fmt == "json": print(json.dumps({"error": "no_stash_entries", "stash_size": 0})) raise SystemExit(ExitCode.USER_ERROR) try: idx = _resolve_index(entries, raw_index) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) dropped = entries.pop(idx) _save_stash(root, entries) if fmt == "json": print(json.dumps({ "status": "dropped", "index": idx, "snapshot_id": dropped["snapshot_id"], "branch": dropped["branch"], "stashed_at": dropped["stashed_at"], "message": dropped.get("message"), "stash_size": len(entries), })) else: label = f" ({sanitize_display(dropped['message'] or '')})" if dropped.get("message") else "" print(f"Dropped stash@{{{idx}}} (branch: {sanitize_display(dropped['branch'])}){label}")