cgcardona / muse public
write_tree.py python
191 lines 7.3 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse write-tree — write the current muse-work/ state as a snapshot (tree) object.
2
3 Plumbing command that mirrors ``git write-tree``. It scans ``muse-work/``,
4 hashes all files, builds a deterministic snapshot manifest, persists both
5 the individual object rows and the snapshot row to Postgres, and prints the
6 ``snapshot_id``.
7
8 Why this exists
9 ---------------
10 Porcelain commands like ``muse commit`` bundle snapshot creation with commit
11 creation and branch-pointer updates. Agents and tooling sometimes need the
12 snapshot object alone — e.g. to compare the current working tree against a
13 reference snapshot without recording history, or to pre-hash the tree before
14 deciding whether to commit. ``muse write-tree`` exposes that primitive.
15
16 Key properties
17 --------------
18 - **Deterministic / idempotent**: same files → same ``snapshot_id``. Running
19 the command twice without changing any files outputs the same ID and makes
20 exactly zero new DB writes (the upsert is a no-op).
21 - **No commit**: the HEAD pointer and branch refs are never modified.
22 - **Prefix filter**: ``--prefix PATH`` restricts the snapshot to files whose
23 relative path starts with *PATH*, enabling per-instrument or per-section
24 snapshots without committing unrelated work.
25 - **Empty-tree handling**: by default the command exits 1 when ``muse-work/``
26 is absent or empty. Pass ``--missing-ok`` to suppress the error and still
27 emit a valid (empty) ``snapshot_id``.
28 """
29 from __future__ import annotations
30
31 import asyncio
32 import logging
33 import pathlib
34 from typing import Optional
35
36 import typer
37 from sqlalchemy.ext.asyncio import AsyncSession
38
39 from maestro.muse_cli._repo import require_repo
40 from maestro.muse_cli.db import open_session, upsert_object, upsert_snapshot
41 from maestro.muse_cli.errors import ExitCode
42 from maestro.muse_cli.snapshot import (
43 build_snapshot_manifest,
44 compute_snapshot_id,
45 hash_file,
46 )
47
48 logger = logging.getLogger(__name__)
49
50 app = typer.Typer()
51
52
53 # ---------------------------------------------------------------------------
54 # Testable async core
55 # ---------------------------------------------------------------------------
56
57
58 async def _write_tree_async(
59 *,
60 root: pathlib.Path,
61 session: AsyncSession,
62 prefix: str | None = None,
63 missing_ok: bool = False,
64 ) -> str:
65 """Hash the working tree, persist snapshot objects, and return the ``snapshot_id``.
66
67 Args:
68 root: Repo root directory (must contain ``muse-work/``).
69 session: Open async DB session. The caller is responsible for
70 committing. ``open_session()`` commits on clean exit.
71 prefix: When set, restrict the snapshot to files whose repo-relative
72 path starts with *prefix*. The prefix is matched against paths
73 of the form ``<prefix>/<rest>`` (no leading slash).
74 missing_ok: When ``True``, an absent or empty ``muse-work/`` is not
75 an error — the command writes an empty snapshot and exits 0.
76 When ``False`` (default), an absent or empty tree exits 1.
77
78 Returns:
79 The 64-character sha256 hex digest that uniquely identifies this
80 snapshot. The same content always returns the same ID.
81
82 Raises:
83 typer.Exit: With ``USER_ERROR`` (1) when ``muse-work/`` is missing or
84 empty and *missing_ok* is ``False``.
85 """
86 workdir = root / "muse-work"
87
88 # ── Build manifest ───────────────────────────────────────────────────
89 if not workdir.exists():
90 if not missing_ok:
91 typer.echo(
92 "⚠️ No muse-work/ directory found. Generate some artifacts first.\n"
93 " Tip: run the Maestro stress test to populate muse-work/.\n"
94 " Or pass --missing-ok to allow an empty tree."
95 )
96 raise typer.Exit(code=ExitCode.USER_ERROR)
97 manifest: dict[str, str] = {}
98 else:
99 manifest = build_snapshot_manifest(workdir)
100
101 # Apply prefix filter when requested.
102 if prefix is not None:
103 # Normalise: strip leading/trailing slashes so callers can pass
104 # either "drums" or "drums/" and get identical results.
105 norm_prefix = prefix.strip("/")
106 manifest = {
107 path: oid
108 for path, oid in manifest.items()
109 if path == norm_prefix or path.startswith(norm_prefix + "/")
110 }
111
112 if not manifest and not missing_ok:
113 if prefix is not None:
114 typer.echo(
115 f"⚠️ No files found under prefix '{prefix}' in muse-work/.\n"
116 " Pass --missing-ok to allow an empty snapshot."
117 )
118 else:
119 typer.echo(
120 "⚠️ muse-work/ is empty — no files to snapshot.\n"
121 " Pass --missing-ok to allow an empty snapshot."
122 )
123 raise typer.Exit(code=ExitCode.USER_ERROR)
124
125 snapshot_id = compute_snapshot_id(manifest)
126
127 # ── Persist object rows (content-addressed, deduped by upsert) ───────
128 for rel_path, object_id in manifest.items():
129 file_path = workdir / rel_path
130 size = file_path.stat().st_size
131 await upsert_object(session, object_id=object_id, size_bytes=size)
132
133 # ── Persist snapshot row ─────────────────────────────────────────────
134 await upsert_snapshot(session, manifest=manifest, snapshot_id=snapshot_id)
135
136 logger.info("✅ muse write-tree snapshot_id=%s files=%d", snapshot_id[:8], len(manifest))
137 return snapshot_id
138
139
140 # ---------------------------------------------------------------------------
141 # Typer command
142 # ---------------------------------------------------------------------------
143
144
145 @app.callback(invoke_without_command=True)
146 def write_tree(
147 ctx: typer.Context,
148 prefix: Optional[str] = typer.Option(
149 None,
150 "--prefix",
151 help=(
152 "Only include files whose path (relative to muse-work/) starts "
153 "with this prefix. Example: --prefix drums/ snapshots only the "
154 "drums sub-directory."
155 ),
156 ),
157 missing_ok: bool = typer.Option(
158 False,
159 "--missing-ok",
160 help=(
161 "Do not fail when muse-work/ is absent or empty (or when --prefix "
162 "matches no files). The empty snapshot_id is still printed."
163 ),
164 ),
165 ) -> None:
166 """Write the current muse-work/ state as a snapshot (tree) object.
167
168 Hashes all files in muse-work/, persists the object and snapshot rows,
169 and prints the snapshot_id. Does NOT create a commit or modify any
170 branch ref.
171 """
172 root = require_repo()
173
174 async def _run() -> None:
175 async with open_session() as session:
176 snapshot_id = await _write_tree_async(
177 root=root,
178 session=session,
179 prefix=prefix,
180 missing_ok=missing_ok,
181 )
182 typer.echo(snapshot_id)
183
184 try:
185 asyncio.run(_run())
186 except typer.Exit:
187 raise
188 except Exception as exc:
189 typer.echo(f"❌ muse write-tree failed: {exc}")
190 logger.error("❌ muse write-tree error: %s", exc, exc_info=True)
191 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)