cgcardona / muse public
read_tree.py python
320 lines 11.0 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse read-tree — read a snapshot into the muse-work/ directory.
2
3 This is a low-level plumbing command (analogous to ``git read-tree``) that
4 hydrates ``muse-work/`` from a stored snapshot manifest WITHOUT touching any
5 branch or HEAD references. It is intentionally destructive by default: any
6 file in ``muse-work/`` whose path appears in the snapshot is overwritten.
7
8 Use cases
9 ---------
10 - Inspect an older snapshot without losing your current branch position.
11 - Restore a specific state before running ``muse commit``.
12 - Agent tooling that needs to populate a clean working directory from a
13 known snapshot (e.g., after ``muse pull`` or before an automated test).
14
15 Flags
16 -----
17 ``<snapshot_id>``
18 Positional — the full or abbreviated (≥ 4 chars) snapshot SHA to restore.
19
20 ``--dry-run``
21 Print the list of files that *would* be written without touching disk.
22 Exit 0 on success.
23
24 ``--reset``
25 Remove all existing files from ``muse-work/`` before populating.
26 Without this flag, only the files referenced by the snapshot are
27 written (files not in the snapshot are left untouched).
28
29 Algorithm
30 ---------
31 1. Resolve *snapshot_id* — accept full 64-char SHA or a ≥4-char prefix via
32 a DB prefix scan.
33 2. Fetch the snapshot manifest: ``{rel_path → object_id}``.
34 3. For each entry, read the object from ``.muse/objects/<object_id>``.
35 4. If ``--reset``: remove all files currently in ``muse-work/``.
36 5. Write each file to ``muse-work/<rel_path>`` (creating parent dirs).
37 6. Does NOT update ``.muse/HEAD`` or any branch ref.
38 """
39 from __future__ import annotations
40
41 import asyncio
42 import logging
43 import pathlib
44
45 import typer
46 from sqlalchemy.ext.asyncio import AsyncSession
47 from sqlalchemy.future import select
48
49 from maestro.muse_cli._repo import require_repo
50 from maestro.muse_cli.db import open_session
51 from maestro.muse_cli.errors import ExitCode
52 from maestro.muse_cli.models import MuseCliSnapshot
53 from maestro.muse_cli.object_store import read_object
54
55 logger = logging.getLogger(__name__)
56
57 app = typer.Typer()
58
59 # Minimum prefix length for abbreviated snapshot IDs.
60 _MIN_PREFIX_LEN = 4
61
62
63 # ---------------------------------------------------------------------------
64 # Result type
65 # ---------------------------------------------------------------------------
66
67
68 class ReadTreeResult:
69 """Structured result of a ``read-tree`` operation.
70
71 Carries the set of files written (or that would have been written in
72 dry-run mode) so that tests can assert on content without inspecting
73 the filesystem.
74
75 Attributes:
76 snapshot_id: Full 64-char snapshot ID that was resolved.
77 files_written: Relative paths of files written to muse-work/.
78 dry_run: True when ``--dry-run`` was requested (no writes made).
79 reset: True when ``--reset`` cleared muse-work/ first.
80 """
81
82 def __init__(
83 self,
84 *,
85 snapshot_id: str,
86 files_written: list[str],
87 dry_run: bool,
88 reset: bool,
89 ) -> None:
90 self.snapshot_id = snapshot_id
91 self.files_written = files_written
92 self.dry_run = dry_run
93 self.reset = reset
94
95 def __repr__(self) -> str:
96 return (
97 f"<ReadTreeResult snap={self.snapshot_id[:8]}"
98 f" files={len(self.files_written)}"
99 f" dry_run={self.dry_run} reset={self.reset}>"
100 )
101
102
103 # ---------------------------------------------------------------------------
104 # Testable async core
105 # ---------------------------------------------------------------------------
106
107
108 async def _resolve_snapshot(
109 session: AsyncSession,
110 snapshot_id: str,
111 ) -> MuseCliSnapshot | None:
112 """Resolve *snapshot_id* to a :class:`MuseCliSnapshot` row.
113
114 Accepts both full 64-char SHAs and abbreviated prefixes (≥ 4 chars).
115 When a prefix is provided, a table scan is used to find the first
116 matching row (acceptable for CLI use; snapshot IDs are content-addressed
117 so collisions are astronomically unlikely).
118
119 Returns ``None`` when no snapshot matches.
120 """
121 if len(snapshot_id) == 64:
122 return await session.get(MuseCliSnapshot, snapshot_id)
123
124 # Abbreviated prefix scan.
125 result = await session.execute(
126 select(MuseCliSnapshot).where(
127 MuseCliSnapshot.snapshot_id.startswith(snapshot_id)
128 )
129 )
130 return result.scalars().first()
131
132
133 async def _read_tree_async(
134 *,
135 snapshot_id: str,
136 root: pathlib.Path,
137 session: AsyncSession,
138 dry_run: bool = False,
139 reset: bool = False,
140 ) -> ReadTreeResult:
141 """Core read-tree logic — fully injectable for tests.
142
143 Resolves the snapshot from the DB, then hydrates ``muse-work/`` from the
144 local object store. Raises ``typer.Exit`` with a clean exit code on any
145 user-facing error so the Typer callback can surface it without a traceback.
146
147 Args:
148 snapshot_id: Full or abbreviated snapshot ID to restore.
149 root: Muse repository root (directory containing ``.muse/``).
150 session: Open async DB session used for snapshot lookup.
151 dry_run: When True, report what would be written but do nothing.
152 reset: When True, clear muse-work/ before populating.
153
154 Returns:
155 :class:`ReadTreeResult` describing what was (or would have been) done.
156
157 Raises:
158 typer.Exit: On user-facing errors (unknown snapshot, missing objects).
159 """
160 if len(snapshot_id) < _MIN_PREFIX_LEN:
161 typer.echo(
162 f"❌ Snapshot ID too short: '{snapshot_id}' "
163 f"(need at least {_MIN_PREFIX_LEN} hex chars)."
164 )
165 raise typer.Exit(code=ExitCode.USER_ERROR)
166
167 # ── 1. Resolve snapshot ──────────────────────────────────────────────
168 snapshot = await _resolve_snapshot(session, snapshot_id.lower())
169 if snapshot is None:
170 typer.echo(f"❌ No snapshot found matching '{snapshot_id[:8]}'.")
171 raise typer.Exit(code=ExitCode.USER_ERROR)
172
173 manifest: dict[str, str] = dict(snapshot.manifest)
174 if not manifest:
175 typer.echo(f"⚠️ Snapshot {snapshot.snapshot_id[:8]} has an empty manifest.")
176 raise typer.Exit(code=ExitCode.USER_ERROR)
177
178 workdir = root / "muse-work"
179 sorted_paths = sorted(manifest.keys())
180
181 # ── 2. Dry-run: list files and exit ─────────────────────────────────
182 if dry_run:
183 typer.echo(f"Snapshot {snapshot.snapshot_id[:8]} — {len(manifest)} file(s):")
184 for rel_path in sorted_paths:
185 obj_id = manifest[rel_path]
186 typer.echo(f" {rel_path} ({obj_id[:8]})")
187 return ReadTreeResult(
188 snapshot_id=snapshot.snapshot_id,
189 files_written=sorted_paths,
190 dry_run=True,
191 reset=reset,
192 )
193
194 # ── 3. Pre-flight: verify all objects are present in the store ───────
195 missing: list[str] = []
196 for rel_path, object_id in manifest.items():
197 content = read_object(root, object_id)
198 if content is None:
199 missing.append(rel_path)
200
201 if missing:
202 typer.echo(
203 f"❌ {len(missing)} object(s) missing from local store "
204 f"(snapshot {snapshot.snapshot_id[:8]}):"
205 )
206 for p in sorted(missing):
207 typer.echo(f" {p} ({manifest[p][:8]})")
208 typer.echo(
209 " Objects are written by 'muse commit'. "
210 "Re-commit this snapshot or fetch it via 'muse pull'."
211 )
212 raise typer.Exit(code=ExitCode.USER_ERROR)
213
214 # ── 4. Optional reset: remove all files from muse-work/ ─────────────
215 if reset and workdir.exists():
216 removed = 0
217 for existing_file in sorted(workdir.rglob("*")):
218 if existing_file.is_file():
219 existing_file.unlink()
220 removed += 1
221 logger.info("⚠️ --reset: removed %d file(s) from muse-work/", removed)
222 # Clean up empty directories left behind.
223 for d in sorted(workdir.rglob("*"), reverse=True):
224 if d.is_dir():
225 try:
226 d.rmdir()
227 except OSError:
228 pass # Not empty — leave it.
229
230 # ── 5. Write objects to muse-work/ ──────────────────────────────────
231 workdir.mkdir(parents=True, exist_ok=True)
232 files_written: list[str] = []
233
234 for rel_path in sorted_paths:
235 object_id = manifest[rel_path]
236 content = read_object(root, object_id)
237 # content is guaranteed non-None by the pre-flight check above.
238 assert content is not None
239
240 dest = workdir / rel_path
241 dest.parent.mkdir(parents=True, exist_ok=True)
242 dest.write_bytes(content)
243 files_written.append(rel_path)
244 logger.debug("✅ Wrote %s (%d bytes)", rel_path, len(content))
245
246 return ReadTreeResult(
247 snapshot_id=snapshot.snapshot_id,
248 files_written=files_written,
249 dry_run=False,
250 reset=reset,
251 )
252
253
254 # ---------------------------------------------------------------------------
255 # Typer command
256 # ---------------------------------------------------------------------------
257
258
259 @app.callback(invoke_without_command=True)
260 def read_tree(
261 ctx: typer.Context,
262 snapshot_id: str = typer.Argument(
263 ...,
264 help=(
265 "Snapshot ID to restore into muse-work/. "
266 "Accepts the full 64-char SHA or an abbreviated prefix (≥ 4 chars)."
267 ),
268 ),
269 dry_run: bool = typer.Option(
270 False,
271 "--dry-run",
272 help="Print the file list without writing anything.",
273 ),
274 reset: bool = typer.Option(
275 False,
276 "--reset",
277 help="Remove all existing muse-work/ files before populating.",
278 ),
279 ) -> None:
280 """Read a snapshot into muse-work/ without updating HEAD.
281
282 Populates muse-work/ with the exact file set recorded in SNAPSHOT_ID.
283 Files not referenced by the snapshot are left untouched unless --reset
284 is specified. HEAD and branch refs are never modified.
285 """
286 root = require_repo()
287
288 async def _run() -> ReadTreeResult:
289 async with open_session() as session:
290 return await _read_tree_async(
291 snapshot_id=snapshot_id,
292 root=root,
293 session=session,
294 dry_run=dry_run,
295 reset=reset,
296 )
297
298 try:
299 result = asyncio.run(_run())
300 except typer.Exit:
301 raise
302 except Exception as exc:
303 typer.echo(f"❌ muse read-tree failed: {exc}")
304 logger.error("❌ muse read-tree error: %s", exc, exc_info=True)
305 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
306
307 if result.dry_run:
308 return
309
310 action = "reset and populated" if result.reset else "populated"
311 typer.echo(
312 f"✅ muse-work/ {action} from snapshot {result.snapshot_id[:8]} "
313 f"({len(result.files_written)} file(s))."
314 )
315 logger.info(
316 "✅ read-tree snapshot=%s files=%d reset=%s",
317 result.snapshot_id[:8],
318 len(result.files_written),
319 result.reset,
320 )