cgcardona / muse public
stash.py python
142 lines 4.1 KB
7ba4aa0b Remove all Maestro legacy code; clean mypy across full muse/ package Gabriel Cardona <gabriel@tellurstori.com> 3d 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 Any
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 def _read_branch(root: pathlib.Path) -> str:
38 head_ref = (root / ".muse" / "HEAD").read_text().strip()
39 return head_ref.removeprefix("refs/heads/").strip()
40
41
42 def _read_repo_id(root: pathlib.Path) -> str:
43 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
44
45
46 def _load_stash(root: pathlib.Path) -> list[dict[str, Any]]:
47 stash_file = root / _STASH_FILE
48 if not stash_file.exists():
49 return []
50 result: list[dict[str, Any]] = json.loads(stash_file.read_text())
51 return result
52
53
54 def _save_stash(root: pathlib.Path, stash: list[dict[str, Any]]) -> None:
55 (root / _STASH_FILE).write_text(json.dumps(stash, indent=2))
56
57
58 @app.callback(invoke_without_command=True)
59 def stash(ctx: typer.Context) -> None:
60 """Save current muse-work/ changes and restore HEAD."""
61 if ctx.invoked_subcommand is not None:
62 return
63 root = require_repo()
64 repo_id = _read_repo_id(root)
65 branch = _read_branch(root)
66 workdir = root / "muse-work"
67
68 manifest = build_snapshot_manifest(workdir)
69 if not manifest:
70 typer.echo("Nothing to stash.")
71 return
72
73 snapshot_id = compute_snapshot_id(manifest)
74 for rel_path, object_id in manifest.items():
75 write_object_from_path(root, object_id, workdir / rel_path)
76
77 stash_entry = {
78 "snapshot_id": snapshot_id,
79 "manifest": manifest,
80 "branch": branch,
81 "stashed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
82 }
83 entries = _load_stash(root)
84 entries.insert(0, stash_entry)
85 _save_stash(root, entries)
86
87 # Restore HEAD
88 head_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
89 if workdir.exists():
90 shutil.rmtree(workdir)
91 workdir.mkdir()
92 for rel_path, object_id in head_manifest.items():
93 restore_object(root, object_id, workdir / rel_path)
94
95 typer.echo(f"Saved working directory (stash@{{0}})")
96
97
98 @app.command("pop")
99 def stash_pop() -> None:
100 """Restore the most recent stash."""
101 root = require_repo()
102 entries = _load_stash(root)
103 if not entries:
104 typer.echo("No stash entries.")
105 raise typer.Exit(code=ExitCode.USER_ERROR)
106
107 entry = entries.pop(0)
108 _save_stash(root, entries)
109
110 workdir = root / "muse-work"
111 if workdir.exists():
112 shutil.rmtree(workdir)
113 workdir.mkdir()
114 for rel_path, object_id in entry["manifest"].items():
115 restore_object(root, object_id, workdir / rel_path)
116
117 typer.echo(f"Restored stash@{{0}} (branch: {entry['branch']})")
118
119
120 @app.command("list")
121 def stash_list() -> None:
122 """List all stash entries."""
123 root = require_repo()
124 entries = _load_stash(root)
125 if not entries:
126 typer.echo("No stash entries.")
127 return
128 for i, entry in enumerate(entries):
129 typer.echo(f"stash@{{{i}}}: WIP on {entry['branch']} — {entry['stashed_at']}")
130
131
132 @app.command("drop")
133 def stash_drop() -> None:
134 """Discard the most recent stash entry."""
135 root = require_repo()
136 entries = _load_stash(root)
137 if not entries:
138 typer.echo("No stash entries.")
139 raise typer.Exit(code=ExitCode.USER_ERROR)
140 entries.pop(0)
141 _save_stash(root, entries)
142 typer.echo("Dropped stash@{0}")