gabriel / muse public
stash.py python
151 lines 4.2 KB
77f04a8f feat: eliminate all Any/object/ignore — strict TypedDicts at every boundary Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """muse stash — temporarily shelve uncommitted changes.
2
3 Saves the current muse-work/ state to ``.muse/stash.json`` and restores
4 the HEAD snapshot to muse-work/.
5
6 Usage::
7
8 muse stash — save current changes and restore HEAD
9 muse stash pop — restore the most recent stash
10 muse stash list — list all stash entries
11 muse stash drop — discard the most recent stash
12 """
13 from __future__ import annotations
14
15 import datetime
16 import json
17 import logging
18 import pathlib
19 import shutil
20 from typing import TypedDict
21
22 import typer
23
24 from muse.core.errors import ExitCode
25 from muse.core.object_store import restore_object, write_object_from_path
26 from muse.core.repo import require_repo
27 from muse.core.snapshot import build_snapshot_manifest, compute_snapshot_id
28 from muse.core.store import get_head_snapshot_manifest, read_snapshot
29
30 logger = logging.getLogger(__name__)
31
32 app = typer.Typer()
33
34 _STASH_FILE = ".muse/stash.json"
35
36
37 class StashEntry(TypedDict):
38 """A single entry in the stash stack."""
39
40 snapshot_id: str
41 manifest: dict[str, str]
42 branch: str
43 stashed_at: str
44
45
46 def _read_branch(root: pathlib.Path) -> str:
47 head_ref = (root / ".muse" / "HEAD").read_text().strip()
48 return head_ref.removeprefix("refs/heads/").strip()
49
50
51 def _read_repo_id(root: pathlib.Path) -> str:
52 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
53
54
55 def _load_stash(root: pathlib.Path) -> list[StashEntry]:
56 stash_file = root / _STASH_FILE
57 if not stash_file.exists():
58 return []
59 result: list[StashEntry] = json.loads(stash_file.read_text())
60 return result
61
62
63 def _save_stash(root: pathlib.Path, stash: list[StashEntry]) -> None:
64 (root / _STASH_FILE).write_text(json.dumps(stash, indent=2))
65
66
67 @app.callback(invoke_without_command=True)
68 def stash(ctx: typer.Context) -> None:
69 """Save current muse-work/ changes and restore HEAD."""
70 if ctx.invoked_subcommand is not None:
71 return
72 root = require_repo()
73 repo_id = _read_repo_id(root)
74 branch = _read_branch(root)
75 workdir = root / "muse-work"
76
77 manifest = build_snapshot_manifest(workdir)
78 if not manifest:
79 typer.echo("Nothing to stash.")
80 return
81
82 snapshot_id = compute_snapshot_id(manifest)
83 for rel_path, object_id in manifest.items():
84 write_object_from_path(root, object_id, workdir / rel_path)
85
86 stash_entry = StashEntry(
87 snapshot_id=snapshot_id,
88 manifest=manifest,
89 branch=branch,
90 stashed_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),
91 )
92 entries = _load_stash(root)
93 entries.insert(0, stash_entry)
94 _save_stash(root, entries)
95
96 # Restore HEAD
97 head_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
98 if workdir.exists():
99 shutil.rmtree(workdir)
100 workdir.mkdir()
101 for rel_path, object_id in head_manifest.items():
102 restore_object(root, object_id, workdir / rel_path)
103
104 typer.echo(f"Saved working directory (stash@{{0}})")
105
106
107 @app.command("pop")
108 def stash_pop() -> None:
109 """Restore the most recent stash."""
110 root = require_repo()
111 entries = _load_stash(root)
112 if not entries:
113 typer.echo("No stash entries.")
114 raise typer.Exit(code=ExitCode.USER_ERROR)
115
116 entry = entries.pop(0)
117 _save_stash(root, entries)
118
119 workdir = root / "muse-work"
120 if workdir.exists():
121 shutil.rmtree(workdir)
122 workdir.mkdir()
123 for rel_path, object_id in entry["manifest"].items():
124 restore_object(root, object_id, workdir / rel_path)
125
126 typer.echo(f"Restored stash@{{0}} (branch: {entry['branch']})")
127
128
129 @app.command("list")
130 def stash_list() -> None:
131 """List all stash entries."""
132 root = require_repo()
133 entries = _load_stash(root)
134 if not entries:
135 typer.echo("No stash entries.")
136 return
137 for i, entry in enumerate(entries):
138 typer.echo(f"stash@{{{i}}}: WIP on {entry['branch']} — {entry['stashed_at']}")
139
140
141 @app.command("drop")
142 def stash_drop() -> None:
143 """Discard the most recent stash entry."""
144 root = require_repo()
145 entries = _load_stash(root)
146 if not entries:
147 typer.echo("No stash entries.")
148 raise typer.Exit(code=ExitCode.USER_ERROR)
149 entries.pop(0)
150 _save_stash(root, entries)
151 typer.echo("Dropped stash@{0}")