stash.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """``muse stash`` β€” temporarily shelve uncommitted changes.
2
3 Saves the current working tree diff to ``.muse/stash.json`` and restores the
4 HEAD snapshot to the working tree, so you can work on something else without
5 committing half-done work.
6
7 Usage::
8
9 muse stash [-m MSG] β€” save current changes and restore HEAD
10 muse stash pop [N] β€” restore stash entry N (default: 0) and remove it
11 muse stash apply [N] β€” restore stash entry N without removing it
12 muse stash show [N] β€” show which files are in stash entry N
13 muse stash list β€” list all stash entries
14 muse stash drop [N] β€” discard stash entry N (default: 0)
15
16 JSON output (``--format json`` or ``--json``) schema for ``stash push``::
17
18 {
19 "status": "stashed | nothing_to_stash",
20 "snapshot_id": "<sha256> | null",
21 "branch": "<branch>",
22 "stashed_at": "<iso8601>",
23 "message": "<annotation | null>",
24 "files_count": <N>,
25 "stash_size": <N>
26 }
27
28 Exit codes::
29
30 0 β€” success
31 1 β€” no stash entries, invalid format, invalid index
32 3 β€” object store integrity error (missing object on pop/apply)
33 """
34
35 from __future__ import annotations
36
37 import argparse
38 import datetime
39 import json
40 import logging
41 import os
42 import pathlib
43 import sys
44 import tempfile
45 from typing import TypedDict
46
47 from muse.core._types import Manifest
48 from muse.core.errors import ExitCode
49 from muse.core.object_store import read_object, write_object_from_path
50 from muse.core.repo import read_repo_id, require_repo
51 from muse.core.ignore import is_ignored
52 from muse.core.snapshot import (
53 compute_snapshot_id,
54 directories_from_manifest,
55 load_ignore_patterns,
56 )
57 from muse.core.store import get_head_snapshot_manifest, read_current_branch
58 from muse.core.validation import assert_not_symlink, sanitize_display
59 from muse.core.workdir import apply_manifest
60 from muse.plugins.registry import resolve_plugin
61
62 _STASH_MAX_BYTES = 64 * 1024 * 1024 # 64 MiB guard against huge stash files
63 _STASH_FILE = ".muse/stash.json"
64
65 logger = logging.getLogger(__name__)
66
67
68 class StashEntry(TypedDict):
69 """A single entry in the stash stack.
70
71 ``delta`` contains only files that differ from HEAD at stash time β€”
72 modified files (new object ID) and newly-added files.
73 ``deleted`` lists paths that existed in HEAD but were removed from the
74 working tree before the stash was created.
75
76 Storing only the delta means ``stash pop`` touches only the files that
77 were actually changed, leaving files that were committed after the stash
78 was created at their newer committed versions.
79 """
80
81 snapshot_id: str
82 delta: Manifest # path β†’ object_id for modified/added files
83 deleted: list[str] # paths removed from working tree relative to HEAD
84 branch: str
85 stashed_at: str
86 message: str | None
87
88
89 def _load_stash(root: pathlib.Path) -> list[StashEntry]:
90 """Load the stash stack from disk, returning an empty list on any error.
91
92 Guards against symlink attacks, oversized files, and malformed JSON.
93 """
94 stash_path = root / _STASH_FILE
95 if not stash_path.exists():
96 return []
97 # Reject symlinks before any read to prevent TOCTOU path-traversal attacks.
98 try:
99 assert_not_symlink(stash_path)
100 except (ValueError, OSError):
101 logger.warning("⚠️ stash.json is a symlink or inaccessible β€” ignoring")
102 return []
103 stat = stash_path.stat()
104 if stat.st_size > _STASH_MAX_BYTES:
105 logger.warning("⚠️ stash.json exceeds size limit (%d bytes) β€” ignoring", stat.st_size)
106 return []
107 try:
108 raw = json.loads(stash_path.read_text(encoding="utf-8"))
109 except (json.JSONDecodeError, OSError):
110 logger.warning("⚠️ stash.json is unreadable or malformed β€” ignoring")
111 return []
112 if not isinstance(raw, list):
113 logger.warning("⚠️ stash.json has unexpected structure β€” ignoring")
114 return []
115 entries: list[StashEntry] = []
116 for item in raw:
117 if not isinstance(item, dict):
118 continue
119 delta = item.get("delta")
120 if not isinstance(delta, dict):
121 continue
122 safe_delta: Manifest = {
123 k: v for k, v in delta.items()
124 if isinstance(k, str) and isinstance(v, str)
125 }
126 raw_deleted = item.get("deleted", [])
127 safe_deleted: list[str] = [
128 p for p in raw_deleted if isinstance(p, str)
129 ] if isinstance(raw_deleted, list) else []
130 entries.append(StashEntry(
131 snapshot_id=str(item.get("snapshot_id", "")),
132 delta=safe_delta,
133 deleted=safe_deleted,
134 branch=str(item.get("branch", "")),
135 stashed_at=str(item.get("stashed_at", "")),
136 message=str(item["message"]) if item.get("message") else None,
137 ))
138 return entries
139
140
141 def _save_stash(root: pathlib.Path, stash: list[StashEntry]) -> None:
142 """Write the stash stack atomically with an fsync barrier.
143
144 Uses temp-file + rename to survive crashes, and flushes to the
145 journal before the rename so the new data is durable on power loss.
146 """
147 target = root / _STASH_FILE
148 payload = json.dumps(stash, indent=2, ensure_ascii=False)
149 fd, tmp_path_str = tempfile.mkstemp(dir=target.parent, prefix=".stash_tmp_", suffix=".json")
150 tmp_path = pathlib.Path(tmp_path_str)
151 try:
152 with os.fdopen(fd, "w", encoding="utf-8") as fh:
153 fh.write(payload)
154 fh.flush()
155 # Durability barrier: flush kernel buffers before the rename so the
156 # new stash data survives a crash between fsync and os.replace.
157 try:
158 import fcntl as _fcntl
159 _fcntl.fcntl(fh.fileno(), 85) # F_BARRIERFSYNC on macOS
160 except (AttributeError, OSError):
161 os.fsync(fh.fileno())
162 os.replace(tmp_path_str, target)
163 except Exception:
164 tmp_path.unlink(missing_ok=True)
165 raise
166
167
168 def _resolve_index(entries: list[StashEntry], raw: str | None, *, default: int = 0) -> int:
169 """Parse an optional stash index argument, validate bounds, return the int.
170
171 Raises ``ValueError`` with a human-readable message on invalid input.
172 """
173 if raw is None:
174 idx = default
175 else:
176 try:
177 idx = int(raw)
178 except ValueError:
179 raise ValueError(f"Invalid stash index {sanitize_display(raw)!r} β€” must be an integer.")
180 if idx < 0 or idx >= len(entries):
181 raise ValueError(
182 f"Stash index {idx} is out of range "
183 f"(0–{len(entries) - 1 if entries else 0})."
184 )
185 return idx
186
187
188 def _verify_manifest_objects(root: pathlib.Path, delta: Manifest) -> list[str]:
189 """Return paths in *delta* whose object IDs are absent from the object store."""
190 missing: list[str] = []
191 for rel_path, obj_id in delta.items():
192 if read_object(root, obj_id) is None:
193 missing.append(rel_path)
194 return missing
195
196
197 def _apply_stash_delta(
198 root: pathlib.Path,
199 delta: Manifest,
200 deleted: list[str],
201 ) -> None:
202 """Restore only the files that were changed when the stash was created.
203
204 Writes each file in *delta* from the object store into the working tree,
205 then removes each path in *deleted*. Files that were unchanged at stash
206 time are never touched, so any commits made to those files after the stash
207 was created are preserved.
208 """
209 from muse.core.object_store import read_object as _read_object
210
211 for rel_path, obj_id in delta.items():
212 data = _read_object(root, obj_id)
213 if data is None:
214 logger.warning("⚠️ stash: object missing for %s β€” skipping", rel_path)
215 continue
216 dest = root / rel_path
217 dest.parent.mkdir(parents=True, exist_ok=True)
218 dest.write_bytes(data)
219
220 for rel_path in deleted:
221 target = root / rel_path
222 try:
223 target.unlink()
224 except FileNotFoundError:
225 pass # already gone β€” idempotent
226
227
228 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
229 """Register the ``muse stash`` subcommand tree and all its flags."""
230 parser = subparsers.add_parser(
231 "stash",
232 help="Temporarily shelve uncommitted changes.",
233 description=__doc__,
234 formatter_class=argparse.RawDescriptionHelpFormatter,
235 )
236 parser.add_argument(
237 "-m", "--message", default=None,
238 help="Annotate this stash entry with a description.",
239 )
240 parser.add_argument(
241 "--include-untracked", "-u", action="store_true", dest="include_untracked",
242 help=(
243 "Include untracked files in the stash. "
244 "In muse, all files are tracked by the domain plugin snapshot, so this flag "
245 "is accepted for compatibility but has no additional effect."
246 ),
247 )
248 parser.add_argument("--format", "-f", default="text", dest="fmt",
249 help="Output format: text or json.")
250 parser.add_argument("--json", action="store_const", const="json", dest="fmt",
251 help="Shorthand for --format json.")
252 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
253
254 apply_p = subs.add_parser(
255 "apply",
256 help="Restore a stash entry without removing it.",
257 description=(
258 "Restore a stash entry to the working tree without removing it from the stack.\n\n"
259 "Unlike ``pop``, the stash entry remains after apply, so you can apply the\n"
260 "same changes to multiple branches or inspect the result before committing.\n\n"
261 "Index rules\n"
262 "-----------\n"
263 " stash@{0} β€” most recently stashed (default)\n"
264 " stash@{N} β€” Nth entry (0-based, error if out of range)\n\n"
265 "Safety\n"
266 "------\n"
267 "All stash objects are verified against the object store before the working\n"
268 "tree is modified. If any object is missing, apply aborts with exit code 3.\n\n"
269 "Agent quickstart\n"
270 "----------------\n"
271 " muse stash apply --json # apply stash@{0}, machine-readable\n"
272 " muse stash apply 2 --json # apply stash@{2} without removing it\n\n"
273 "JSON output schema\n"
274 "------------------\n"
275 ' {"status": "applied", "snapshot_id": "<sha256>",\n'
276 ' "branch": "<branch>", "stashed_at": "<iso8601>",\n'
277 ' "message": "<annotation> | null", "files_count": N,\n'
278 ' "stash_size": N}\n\n'
279 "Exit codes\n"
280 "----------\n"
281 " 0 β€” success\n"
282 " 1 β€” no stash entries, invalid format, out-of-range or non-integer index\n"
283 " 2 β€” not inside a Muse repository\n"
284 " 3 β€” missing objects in object store (stash is corrupt)\n"
285 ),
286 formatter_class=argparse.RawDescriptionHelpFormatter,
287 )
288 apply_p.add_argument("index", nargs="?", default=None,
289 help="Index of the stash entry to apply (default: 0).")
290 apply_p.add_argument("--format", "-f", default="text", dest="fmt",
291 help="Output format: text or json.")
292 apply_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
293 help="Shorthand for --format json.")
294 apply_p.set_defaults(func=run_apply)
295
296 drop_p = subs.add_parser(
297 "drop",
298 help="Discard a stash entry.",
299 description=(
300 "Discard a stash entry without applying it to the working tree.\n\n"
301 "Index rules\n"
302 "-----------\n"
303 " stash@{0} β€” most recently stashed (default)\n"
304 " stash@{N} β€” Nth entry (0-based, error if out of range)\n\n"
305 "Agent quickstart\n"
306 "----------------\n"
307 " muse stash drop --json # drop stash@{0}, machine-readable\n"
308 " muse stash drop 2 --json # drop stash@{2}\n\n"
309 "JSON output schema\n"
310 "------------------\n"
311 ' {"status": "dropped", "index": N, "snapshot_id": "<sha256>",\n'
312 ' "branch": "<branch>", "stashed_at": "<iso8601>",\n'
313 ' "message": "<annotation> | null", "stash_size": N}\n\n'
314 "Exit codes\n"
315 "----------\n"
316 " 0 β€” success\n"
317 " 1 β€” no stash entries, invalid format, out-of-range or non-integer index\n"
318 " 2 β€” not inside a Muse repository\n"
319 ),
320 formatter_class=argparse.RawDescriptionHelpFormatter,
321 )
322 drop_p.add_argument("index", nargs="?", default=None,
323 help="Index of the stash entry to drop (default: 0).")
324 drop_p.add_argument("--format", "-f", default="text", dest="fmt",
325 help="Output format: text or json.")
326 drop_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
327 help="Shorthand for --format json.")
328 drop_p.set_defaults(func=run_drop)
329
330 list_p = subs.add_parser(
331 "list",
332 help="List all stash entries.",
333 description=(
334 "List all stash entries in LIFO order (most-recently stashed first).\n\n"
335 "An empty stash is a valid result β€” exit code is always 0.\n\n"
336 "Agent quickstart\n"
337 "----------------\n"
338 " muse stash list --json # full machine-readable list\n"
339 " muse stash list --json | jq 'length' # count stash entries\n"
340 " muse stash list --json | jq '.[0].message' # annotation of top entry\n\n"
341 "JSON output schema\n"
342 "------------------\n"
343 " Array of objects, one per stash entry:\n"
344 ' [{"index": N, "snapshot_id": "<sha256>",\n'
345 ' "branch": "<branch>", "stashed_at": "<iso8601>",\n'
346 ' "message": "<annotation> | null", "files_count": N}, ...]\n\n'
347 "Exit codes\n"
348 "----------\n"
349 " 0 β€” success (including empty stash)\n"
350 " 1 β€” invalid format\n"
351 " 2 β€” not inside a Muse repository\n"
352 ),
353 formatter_class=argparse.RawDescriptionHelpFormatter,
354 )
355 list_p.add_argument("--format", "-f", default="text", dest="fmt",
356 help="Output format: text or json.")
357 list_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
358 help="Shorthand for --format json.")
359 list_p.set_defaults(func=run_list)
360
361 pop_p = subs.add_parser(
362 "pop",
363 help="Restore a stash entry and remove it from the stack.",
364 description=(
365 "Restore a stash entry to the working tree and remove it from the stash stack.\n\n"
366 "Index rules\n"
367 "-----------\n"
368 " stash@{0} β€” most recently stashed (default)\n"
369 " stash@{1} β€” second-most-recently stashed\n"
370 " stash@{N} β€” Nth entry (0-based, error if out of range)\n\n"
371 "Safety\n"
372 "------\n"
373 "All stash objects are verified against the object store before the working\n"
374 "tree is modified. If any object is missing, pop aborts with exit code 3\n"
375 "to prevent a corrupt workdir.\n\n"
376 "Agent quickstart\n"
377 "----------------\n"
378 " muse stash pop --json # pop stash@{0}, machine-readable result\n"
379 " muse stash pop 2 --json # pop stash@{2}\n\n"
380 "JSON output schema\n"
381 "------------------\n"
382 ' {"status": "popped", "snapshot_id": "<sha256>",\n'
383 ' "branch": "<branch>", "stashed_at": "<iso8601>",\n'
384 ' "message": "<annotation> | null", "files_count": N,\n'
385 ' "stash_size_after": N}\n\n'
386 "Exit codes\n"
387 "----------\n"
388 " 0 β€” success\n"
389 " 1 β€” no stash entries, invalid format, out-of-range or non-integer index\n"
390 " 2 β€” not inside a Muse repository\n"
391 " 3 β€” missing objects in object store (stash is corrupt)\n"
392 ),
393 formatter_class=argparse.RawDescriptionHelpFormatter,
394 )
395 pop_p.add_argument("index", nargs="?", default=None,
396 help="Index of the stash entry to pop (default: 0).")
397 pop_p.add_argument("--format", "-f", default="text", dest="fmt",
398 help="Output format: text or json.")
399 pop_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
400 help="Shorthand for --format json.")
401 pop_p.set_defaults(func=run_pop)
402
403 show_p = subs.add_parser(
404 "show",
405 help="Show the files in a stash entry.",
406 description=(
407 "Show which files are contained in a stash entry without modifying the working tree.\n\n"
408 "Index rules\n"
409 "-----------\n"
410 " stash@{0} β€” most recently stashed (default)\n"
411 " stash@{N} β€” Nth entry (0-based, error if out of range)\n\n"
412 "Agent quickstart\n"
413 "----------------\n"
414 " muse stash show --json # inspect stash@{0}\n"
415 " muse stash show 2 --json # inspect stash@{2}\n"
416 " muse stash show --json | jq '.files[]' # list stashed paths\n\n"
417 "JSON output schema\n"
418 "------------------\n"
419 ' {"index": N, "snapshot_id": "<sha256>",\n'
420 ' "branch": "<branch>", "stashed_at": "<iso8601>",\n'
421 ' "message": "<annotation> | null",\n'
422 ' "files_count": N, "files": ["path/to/file.py", ...]}\n\n'
423 "Exit codes\n"
424 "----------\n"
425 " 0 β€” success\n"
426 " 1 β€” no stash entries, invalid format, out-of-range or non-integer index\n"
427 " 2 β€” not inside a Muse repository\n"
428 ),
429 formatter_class=argparse.RawDescriptionHelpFormatter,
430 )
431 show_p.add_argument("index", nargs="?", default=None,
432 help="Index of the stash entry to show (default: 0).")
433 show_p.add_argument("--format", "-f", default="text", dest="fmt",
434 help="Output format: text or json.")
435 show_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
436 help="Shorthand for --format json.")
437 show_p.set_defaults(func=run_show)
438
439 parser.set_defaults(func=run)
440
441
442 # ── Programmatic API (used by checkout --autostash) ───────────────────────────
443
444 def _stash_push_programmatic(
445 root: pathlib.Path,
446 message: str | None = None,
447 ) -> "StashEntry | None":
448 """Save current working-tree changes to the stash without CLI output.
449
450 Returns the new :class:`StashEntry` if changes were stashed, or ``None``
451 when the working tree is already clean (nothing to stash).
452
453 This is the same logic as :func:`run` but without argparse, format
454 handling, or prints β€” safe to call from other commands such as
455 ``muse checkout --autostash``.
456
457 Args:
458 root: Repository root (the directory that contains ``.muse/``).
459 message: Optional annotation stored alongside the entry.
460
461 Returns:
462 The stash entry that was pushed, or ``None`` if the tree was clean.
463 """
464 repo_id = read_repo_id(root)
465 branch = read_current_branch(root)
466 plugin = resolve_plugin(root)
467 manifest = plugin.snapshot(root)["files"]
468
469 head_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
470 if manifest == head_manifest:
471 return None
472
473 delta: Manifest = {
474 path: oid
475 for path, oid in manifest.items()
476 if head_manifest.get(path) != oid
477 }
478 _ignore_patterns = load_ignore_patterns(root)
479 deleted: list[str] = [
480 path for path in head_manifest
481 if path not in manifest
482 and not (is_ignored(path, _ignore_patterns) and (root / path).exists())
483 ]
484
485 snapshot_id = compute_snapshot_id(manifest, directories_from_manifest(manifest))
486 for rel_path, object_id in delta.items():
487 write_object_from_path(root, object_id, root / rel_path)
488
489 stashed_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
490 entry = StashEntry(
491 snapshot_id=snapshot_id,
492 delta=delta,
493 deleted=deleted,
494 branch=branch,
495 stashed_at=stashed_at,
496 message=message,
497 )
498 entries = _load_stash(root)
499 entries.insert(0, entry)
500 _save_stash(root, entries)
501
502 apply_manifest(root, head_manifest)
503 logger.debug("autostash: stashed %d file(s) as stash@{0}", len(delta) + len(deleted))
504 return entry
505
506
507 def _stash_pop_programmatic(root: pathlib.Path, index: int = 0) -> "StashEntry":
508 """Restore a stash entry and remove it from the stack without CLI output.
509
510 Mirrors the logic in :func:`run_pop` but raises exceptions instead of
511 calling :func:`sys.exit`, making it safe to call from other commands such
512 as ``muse checkout --autostash``.
513
514 Args:
515 root: Repository root (the directory that contains ``.muse/``).
516 index: Zero-based index into the stash stack (default ``0`` = most
517 recent entry).
518
519 Returns:
520 The :class:`StashEntry` that was applied and removed.
521
522 Raises:
523 ValueError: If the stash is empty or *index* is out of range.
524 RuntimeError: If objects are missing from the object store.
525 """
526 entries = _load_stash(root)
527 if not entries:
528 raise ValueError("No stash entries to pop.")
529 if index < 0 or index >= len(entries):
530 raise ValueError(
531 f"Stash index {index} is out of range (0–{len(entries) - 1})."
532 )
533
534 entry = entries[index]
535 try:
536 missing = _verify_manifest_objects(root, entry["delta"])
537 except OSError as exc:
538 raise RuntimeError(
539 f"Stash entry {index} failed object-store integrity check: {exc}"
540 ) from exc
541 if missing:
542 raise RuntimeError(
543 f"Stash entry {index} is missing {len(missing)} object(s) from "
544 f"the object store: {', '.join(sorted(missing))}"
545 )
546
547 entries.pop(index)
548 _save_stash(root, entries)
549 _apply_stash_delta(root, entry["delta"], entry["deleted"])
550 logger.debug("autostash: restored stash@{%d} (%d file(s))", index, len(entry["delta"]) + len(entry["deleted"]))
551 return entry
552
553
554 def run(args: argparse.Namespace) -> None:
555 """Save current working-tree changes and restore HEAD.
556
557 Serialises a manifest of current tracked files into ``.muse/stash.json``
558 and writes each file's content into the object store. Then restores the
559 HEAD snapshot so the working tree is clean.
560
561 All error messages are written to **stderr**; stdout is reserved for
562 structured output only.
563
564 Agents should pass ``--format json`` for a stable schema::
565
566 {
567 "status": "stashed | nothing_to_stash",
568 "snapshot_id": "<sha256> | null",
569 "branch": "<branch>",
570 "stashed_at": "<iso8601>",
571 "message": "<annotation> | null",
572 "files_count": <N>,
573 "stash_size": <N>
574 }
575
576 Exit codes::
577
578 0 β€” success (stashed or nothing_to_stash)
579 1 β€” invalid format
580 """
581 fmt: str = args.fmt
582 message: str | None = args.message
583
584 if fmt not in ("text", "json"):
585 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
586 file=sys.stderr)
587 raise SystemExit(ExitCode.USER_ERROR)
588
589 root = require_repo()
590 repo_id = read_repo_id(root)
591 branch = read_current_branch(root)
592 plugin = resolve_plugin(root)
593 manifest = plugin.snapshot(root)["files"]
594
595 head_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
596 if manifest == head_manifest:
597 if fmt == "json":
598 print(json.dumps({
599 "status": "nothing_to_stash",
600 "snapshot_id": None,
601 "branch": branch,
602 "stashed_at": None,
603 "message": None,
604 "files_count": 0,
605 "stash_size": len(_load_stash(root)),
606 }))
607 else:
608 print("Nothing to stash.")
609 return
610
611 # Compute delta: only files that differ from HEAD (modified or newly added).
612 delta: Manifest = {
613 path: oid
614 for path, oid in manifest.items()
615 if head_manifest.get(path) != oid
616 }
617 # Deleted: in HEAD but genuinely absent from the working tree.
618 # Files that are now in .museignore and still present on disk are excluded
619 # β€” they are not deleted, just moved out of tracking. Recording them as
620 # deleted would cause stash pop to unlink them from disk.
621 _ignore_patterns = load_ignore_patterns(root)
622 deleted: list[str] = [
623 path for path in head_manifest
624 if path not in manifest
625 and not (is_ignored(path, _ignore_patterns) and (root / path).exists())
626 ]
627
628 snapshot_id = compute_snapshot_id(manifest, directories_from_manifest(manifest))
629 # Only write objects for changed/added files β€” unchanged files are already
630 # in the object store from the HEAD commit.
631 for rel_path, object_id in delta.items():
632 write_object_from_path(root, object_id, root / rel_path)
633
634 stashed_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
635 stash_entry = StashEntry(
636 snapshot_id=snapshot_id,
637 delta=delta,
638 deleted=deleted,
639 branch=branch,
640 stashed_at=stashed_at,
641 message=message,
642 )
643 entries = _load_stash(root)
644 entries.insert(0, stash_entry)
645 _save_stash(root, entries)
646
647 apply_manifest(root, head_manifest)
648
649 if fmt == "json":
650 print(json.dumps({
651 "status": "stashed",
652 "snapshot_id": snapshot_id,
653 "branch": branch,
654 "stashed_at": stashed_at,
655 "message": message,
656 "files_count": len(delta) + len(deleted),
657 "stash_size": len(entries),
658 }))
659 else:
660 label = f" ({message})" if message else ""
661 print(f"Saved working directory (stash@{{0}}){label}")
662
663
664 def run_pop(args: argparse.Namespace) -> None:
665 """Restore a stash entry and remove it from the stack.
666
667 Validates that all objects in the stash manifest exist in the object
668 store before modifying the working tree. Exits with ``INTERNAL_ERROR``
669 (3) if any objects are missing to prevent a corrupt workdir.
670
671 JSON schema::
672
673 {
674 "status": "popped",
675 "snapshot_id": "<sha256>",
676 "branch": "<branch>",
677 "stashed_at": "<iso8601>",
678 "message": "<annotation> | null",
679 "files_count": <N>,
680 "stash_size_after": <N>
681 }
682
683 Exit codes::
684
685 0 β€” success
686 1 β€” no stash entries, invalid format, out-of-range index
687 2 β€” not inside a Muse repository
688 3 β€” missing objects in object store
689 """
690 fmt: str = args.fmt
691 raw_index: str | None = getattr(args, "index", None)
692
693 if fmt not in ("text", "json"):
694 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
695 file=sys.stderr)
696 raise SystemExit(ExitCode.USER_ERROR)
697
698 root = require_repo()
699 entries = _load_stash(root)
700 if not entries:
701 print("❌ No stash entries.", file=sys.stderr)
702 if fmt == "json":
703 print(json.dumps({"error": "no_stash_entries", "stash_size": 0}))
704 raise SystemExit(ExitCode.USER_ERROR)
705
706 try:
707 idx = _resolve_index(entries, raw_index)
708 except ValueError as exc:
709 print(f"❌ {exc}", file=sys.stderr)
710 raise SystemExit(ExitCode.USER_ERROR)
711
712 entry = entries[idx]
713 missing = _verify_manifest_objects(root, entry["delta"])
714 if missing:
715 print(
716 f"❌ Stash entry {idx} is missing {len(missing)} object(s) from the object store β€” "
717 "aborting to prevent data loss.",
718 file=sys.stderr,
719 )
720 for p in sorted(missing):
721 print(f" missing: {sanitize_display(p)}", file=sys.stderr)
722 raise SystemExit(ExitCode.INTERNAL_ERROR)
723
724 entries.pop(idx)
725 _save_stash(root, entries)
726 _apply_stash_delta(root, entry["delta"], entry["deleted"])
727
728 files_count = len(entry["delta"]) + len(entry["deleted"])
729 if fmt == "json":
730 print(json.dumps({
731 "status": "popped",
732 "snapshot_id": entry["snapshot_id"],
733 "branch": entry["branch"],
734 "stashed_at": entry["stashed_at"],
735 "message": entry.get("message"),
736 "files_count": files_count,
737 "stash_size_after": len(entries),
738 }))
739 else:
740 label = f" ({sanitize_display(entry['message'] or '')})" if entry.get("message") else ""
741 print(
742 f"Restored stash@{{{idx}}} "
743 f"(branch: {sanitize_display(entry['branch'])}){label}"
744 )
745
746
747 def run_apply(args: argparse.Namespace) -> None:
748 """Restore a stash entry without removing it from the stack.
749
750 Useful for applying the same stash to multiple branches.
751
752 JSON schema::
753
754 {
755 "status": "applied",
756 "snapshot_id": "<sha256>",
757 "branch": "<branch>",
758 "stashed_at": "<iso8601>",
759 "message": "<annotation> | null",
760 "files_count": <N>,
761 "stash_size": <N>
762 }
763
764 Exit codes::
765
766 0 β€” success
767 1 β€” no stash entries, invalid format, out-of-range index
768 2 β€” not inside a Muse repository
769 3 β€” missing objects in object store
770 """
771 fmt: str = args.fmt
772 raw_index: str | None = getattr(args, "index", None)
773
774 if fmt not in ("text", "json"):
775 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
776 file=sys.stderr)
777 raise SystemExit(ExitCode.USER_ERROR)
778
779 root = require_repo()
780 entries = _load_stash(root)
781 if not entries:
782 print("❌ No stash entries.", file=sys.stderr)
783 if fmt == "json":
784 print(json.dumps({"error": "no_stash_entries", "stash_size": 0}))
785 raise SystemExit(ExitCode.USER_ERROR)
786
787 try:
788 idx = _resolve_index(entries, raw_index)
789 except ValueError as exc:
790 print(f"❌ {exc}", file=sys.stderr)
791 raise SystemExit(ExitCode.USER_ERROR)
792
793 entry = entries[idx]
794 missing = _verify_manifest_objects(root, entry["delta"])
795 if missing:
796 print(
797 f"❌ Stash entry {idx} is missing {len(missing)} object(s) from the object store β€” "
798 "aborting to prevent data loss.",
799 file=sys.stderr,
800 )
801 for p in sorted(missing):
802 print(f" missing: {sanitize_display(p)}", file=sys.stderr)
803 raise SystemExit(ExitCode.INTERNAL_ERROR)
804
805 _apply_stash_delta(root, entry["delta"], entry["deleted"])
806
807 files_count = len(entry["delta"]) + len(entry["deleted"])
808 if fmt == "json":
809 print(json.dumps({
810 "status": "applied",
811 "snapshot_id": entry["snapshot_id"],
812 "branch": entry["branch"],
813 "stashed_at": entry["stashed_at"],
814 "message": entry.get("message"),
815 "files_count": files_count,
816 "stash_size": len(entries),
817 }))
818 else:
819 label = f" ({sanitize_display(entry['message'] or '')})" if entry.get("message") else ""
820 print(
821 f"Applied stash@{{{idx}}} "
822 f"(branch: {sanitize_display(entry['branch'])}){label} β€” stash entry preserved"
823 )
824
825
826 def run_show(args: argparse.Namespace) -> None:
827 """Show which files are contained in a stash entry.
828
829 JSON schema::
830
831 {
832 "index": <N>,
833 "snapshot_id": "<sha256>",
834 "branch": "<branch>",
835 "stashed_at": "<iso8601>",
836 "message": "<annotation> | null",
837 "files_count": <N>,
838 "files": ["path/to/file.py", ...]
839 }
840
841 Exit codes::
842
843 0 β€” success
844 1 β€” no stash entries, invalid format, out-of-range index
845 2 β€” not inside a Muse repository
846 """
847 fmt: str = args.fmt
848 raw_index: str | None = getattr(args, "index", None)
849
850 if fmt not in ("text", "json"):
851 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
852 file=sys.stderr)
853 raise SystemExit(ExitCode.USER_ERROR)
854
855 root = require_repo()
856 entries = _load_stash(root)
857 if not entries:
858 print("❌ No stash entries.", file=sys.stderr)
859 if fmt == "json":
860 print(json.dumps({"error": "no_stash_entries", "stash_size": 0}))
861 raise SystemExit(ExitCode.USER_ERROR)
862
863 try:
864 idx = _resolve_index(entries, raw_index)
865 except ValueError as exc:
866 print(f"❌ {exc}", file=sys.stderr)
867 raise SystemExit(ExitCode.USER_ERROR)
868
869 entry = entries[idx]
870 modified = sorted(entry["delta"].keys())
871 deleted = sorted(entry["deleted"])
872 files_count = len(modified) + len(deleted)
873
874 if fmt == "json":
875 print(json.dumps({
876 "index": idx,
877 "snapshot_id": entry["snapshot_id"],
878 "branch": entry["branch"],
879 "stashed_at": entry["stashed_at"],
880 "message": entry.get("message"),
881 "files_count": files_count,
882 "files": modified,
883 "deleted": deleted,
884 }))
885 else:
886 label = f" β€” {sanitize_display(entry['message'] or '')}" if entry.get("message") else ""
887 print(f"stash@{{{idx}}}: WIP on {sanitize_display(entry['branch'])}{label}")
888 for f in modified:
889 print(f" M {sanitize_display(f)}")
890 for f in deleted:
891 print(f" D {sanitize_display(f)}")
892
893
894 def run_list(args: argparse.Namespace) -> None:
895 """List all stash entries.
896
897 JSON schema (array)::
898
899 [
900 {
901 "index": <N>,
902 "snapshot_id": "<sha256>",
903 "branch": "<branch>",
904 "stashed_at": "<iso8601>",
905 "message": "<annotation> | null",
906 "files_count": <N>
907 },
908 ...
909 ]
910
911 Exit codes::
912
913 0 β€” always (empty list is a valid result)
914 1 β€” invalid format
915 2 β€” not inside a Muse repository
916 """
917 fmt: str = args.fmt
918
919 if fmt not in ("text", "json"):
920 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
921 file=sys.stderr)
922 raise SystemExit(ExitCode.USER_ERROR)
923
924 root = require_repo()
925 entries = _load_stash(root)
926
927 if fmt == "json":
928 print(json.dumps([{
929 "index": i,
930 "snapshot_id": e["snapshot_id"],
931 "branch": e["branch"],
932 "stashed_at": e["stashed_at"],
933 "message": e.get("message"),
934 "files_count": len(e["delta"]) + len(e["deleted"]),
935 } for i, e in enumerate(entries)]))
936 return
937
938 if not entries:
939 print("No stash entries.")
940 return
941 for i, entry in enumerate(entries):
942 label = f": {sanitize_display(entry['message'] or '')}" if entry.get("message") else ""
943 print(
944 f"stash@{{{i}}}: WIP on {sanitize_display(entry['branch'])} "
945 f"β€” {sanitize_display(entry['stashed_at'])}{label}"
946 )
947
948
949 def run_drop(args: argparse.Namespace) -> None:
950 """Discard a stash entry without applying it.
951
952 JSON schema::
953
954 {
955 "status": "dropped",
956 "index": <N>,
957 "snapshot_id": "<sha256>",
958 "branch": "<branch>",
959 "stashed_at": "<iso8601>",
960 "message": "<annotation> | null",
961 "stash_size": <N>
962 }
963
964 Exit codes::
965
966 0 β€” success
967 1 β€” no stash entries, invalid format, out-of-range index
968 2 β€” not inside a Muse repository
969 """
970 fmt: str = args.fmt
971 raw_index: str | None = getattr(args, "index", None)
972
973 if fmt not in ("text", "json"):
974 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
975 file=sys.stderr)
976 raise SystemExit(ExitCode.USER_ERROR)
977
978 root = require_repo()
979 entries = _load_stash(root)
980 if not entries:
981 print("❌ No stash entries.", file=sys.stderr)
982 if fmt == "json":
983 print(json.dumps({"error": "no_stash_entries", "stash_size": 0}))
984 raise SystemExit(ExitCode.USER_ERROR)
985
986 try:
987 idx = _resolve_index(entries, raw_index)
988 except ValueError as exc:
989 print(f"❌ {exc}", file=sys.stderr)
990 raise SystemExit(ExitCode.USER_ERROR)
991
992 dropped = entries.pop(idx)
993 _save_stash(root, entries)
994
995 if fmt == "json":
996 print(json.dumps({
997 "status": "dropped",
998 "index": idx,
999 "snapshot_id": dropped["snapshot_id"],
1000 "branch": dropped["branch"],
1001 "stashed_at": dropped["stashed_at"],
1002 "message": dropped.get("message"),
1003 "stash_size": len(entries),
1004 }))
1005 else:
1006 label = f" ({sanitize_display(dropped['message'] or '')})" if dropped.get("message") else ""
1007 print(f"Dropped stash@{{{idx}}} (branch: {sanitize_display(dropped['branch'])}){label}")