cgcardona / muse public
muse_reset.py python
352 lines 13.1 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Muse Reset Service — move the branch pointer to a prior commit.
2
3 Implements three reset modes that mirror git's semantics, adapted for the
4 Muse VCS filesystem model (``muse-work/`` working tree, ``.muse/refs/`` branch
5 pointers, ``.muse/objects/`` content-addressed blob store):
6
7 - **soft** — advance/retreat the branch ref; muse-work/ and the object
8 store are left completely untouched. A subsequent ``muse commit``
9 captures the current working tree on top of the new HEAD.
10
11 - **mixed** (default) — same as soft for the branch ref; semantically
12 marks the index as "unstaged". In the current Muse model (no explicit
13 staging area) this is equivalent to soft. Exists for API symmetry with
14 git and for forward-compatibility when a staging index is added.
15
16 - **hard** — moves the branch ref AND overwrites ``muse-work/`` with the
17 exact file contents captured in the target commit's snapshot. Files are
18 restored via :mod:`maestro.muse_cli.object_store` (the canonical blob
19 store shared by all Muse commands). Any files in ``muse-work/`` that
20 are NOT in the target snapshot are deleted.
21
22 HEAD~N syntax
23 -------------
24 ``resolve_ref`` understands ``HEAD``, ``HEAD~N``, a full 64-char SHA, and
25 any SHA prefix of ≥ 4 characters. N-step parent traversal walks
26 ``parent_commit_id`` only (primary parent for linear history); merge
27 parents (``parent2_commit_id``) are ignored for the ``~N`` walk.
28
29 Merge-in-progress guard
30 -----------------------
31 Reset is blocked when ``.muse/MERGE_STATE.json`` exists. A merge in
32 progress must be completed or aborted before resetting.
33
34 Object store contract
35 ---------------------
36 Hard reset requires that every object in the target snapshot's manifest
37 exists in ``.muse/objects/``. Objects are written there by ``muse commit``
38 via :mod:`maestro.muse_cli.object_store`. If an object is missing, hard
39 reset raises ``MissingObjectError`` rather than silently leaving the working
40 tree in a partial state.
41
42 This module is a pure service layer — no Typer, no CLI, no StateStore.
43 Import boundary: may import muse_cli.{db,models,merge_engine,snapshot,
44 object_store}, but NOT executor, maestro_handlers, mcp, or StateStore.
45 """
46 from __future__ import annotations
47
48 import enum
49 import logging
50 import pathlib
51 import re
52 from dataclasses import dataclass
53
54 from sqlalchemy.ext.asyncio import AsyncSession
55
56 from maestro.muse_cli.db import (
57 get_commit_snapshot_manifest,
58 )
59 from maestro.muse_cli.merge_engine import read_merge_state
60 from maestro.muse_cli.models import MuseCliCommit
61 from maestro.muse_cli.object_store import has_object, object_path, restore_object
62
63 logger = logging.getLogger(__name__)
64
65 # ---------------------------------------------------------------------------
66 # Public types
67 # ---------------------------------------------------------------------------
68
69 _HEAD_TILDE_RE = re.compile(r"^HEAD~(\d+)$", re.IGNORECASE)
70
71
72 class ResetMode(str, enum.Enum):
73 """Three-level reset hierarchy, mirroring git semantics.
74
75 Attributes:
76 SOFT: Move branch pointer only; working tree and object store unchanged.
77 MIXED: Move branch pointer and conceptually reset the index.
78 Equivalent to SOFT in the current Muse model (no staging area).
79 HARD: Move branch pointer AND overwrite muse-work/ with the target snapshot.
80 """
81
82 SOFT = "soft"
83 MIXED = "mixed"
84 HARD = "hard"
85
86
87 @dataclass(frozen=True)
88 class ResetResult:
89 """Outcome of a completed ``muse reset`` operation.
90
91 Attributes:
92 target_commit_id: Full SHA of the commit the branch now points to.
93 mode: The reset mode that was applied.
94 branch: Name of the branch that was reset.
95 files_restored: Number of files written to muse-work/ (hard only).
96 files_deleted: Number of files deleted from muse-work/ (hard only).
97 """
98
99 target_commit_id: str
100 mode: ResetMode
101 branch: str
102 files_restored: int = 0
103 files_deleted: int = 0
104
105
106 class MissingObjectError(Exception):
107 """Raised when a hard reset cannot find required blob content.
108
109 Attributes:
110 object_id: The missing content-addressed object SHA.
111 rel_path: File path in the snapshot that required this object.
112 """
113
114 def __init__(self, object_id: str, rel_path: str) -> None:
115 super().__init__(
116 f"Object {object_id[:8]} missing from .muse/objects/ "
117 f"(required by {rel_path!r}). "
118 "Commit the working tree first to populate the object store."
119 )
120 self.object_id = object_id
121 self.rel_path = rel_path
122
123
124 # ---------------------------------------------------------------------------
125 # Ref resolution
126 # ---------------------------------------------------------------------------
127
128
129 async def resolve_ref(
130 session: AsyncSession,
131 repo_id: str,
132 branch: str,
133 ref: str,
134 ) -> MuseCliCommit | None:
135 """Resolve a user-supplied commit reference to a ``MuseCliCommit`` row.
136
137 Understands the following ref syntaxes (all case-insensitive for keywords):
138
139 - ``HEAD`` — most recent commit on *branch*.
140 - ``HEAD~N`` — N steps back from HEAD along the primary parent chain.
141 - ``<sha>`` — exact 64-character commit SHA.
142 - ``<prefix>`` — any prefix of ≥ 1 character; returns first match.
143
144 Args:
145 session: Open async DB session.
146 repo_id: Repository ID (from ``.muse/repo.json``).
147 branch: Current branch name (used for HEAD resolution).
148 ref: User-supplied reference string.
149
150 Returns:
151 The resolved ``MuseCliCommit`` row, or ``None`` when not found.
152 """
153 from sqlalchemy.future import select
154
155 # ── HEAD or HEAD~N ───────────────────────────────────────────────────
156 tilde_match = _HEAD_TILDE_RE.match(ref)
157 is_head = ref.upper() == "HEAD"
158
159 if is_head or tilde_match:
160 # Resolve HEAD first
161 result = await session.execute(
162 select(MuseCliCommit)
163 .where(
164 MuseCliCommit.repo_id == repo_id,
165 MuseCliCommit.branch == branch,
166 )
167 .order_by(MuseCliCommit.committed_at.desc())
168 .limit(1)
169 )
170 head_commit = result.scalar_one_or_none()
171 if head_commit is None:
172 return None
173 if is_head:
174 return head_commit
175
176 # Walk N parents back (primary parent only)
177 assert tilde_match is not None # guaranteed: tilde_match truthy → not None
178 n_steps = int(tilde_match.group(1))
179 current: MuseCliCommit | None = head_commit
180 for _ in range(n_steps):
181 if current is None or not current.parent_commit_id:
182 return None
183 current = await session.get(MuseCliCommit, current.parent_commit_id)
184 return current
185
186 # ── Exact SHA match ──────────────────────────────────────────────────
187 if len(ref) == 64:
188 return await session.get(MuseCliCommit, ref)
189
190 # ── SHA prefix match ─────────────────────────────────────────────────
191 result2 = await session.execute(
192 select(MuseCliCommit).where(
193 MuseCliCommit.repo_id == repo_id,
194 MuseCliCommit.commit_id.startswith(ref),
195 )
196 )
197 return result2.scalars().first()
198
199
200 # ---------------------------------------------------------------------------
201 # Core reset logic
202 # ---------------------------------------------------------------------------
203
204
205 async def perform_reset(
206 *,
207 root: pathlib.Path,
208 session: AsyncSession,
209 ref: str,
210 mode: ResetMode,
211 ) -> ResetResult:
212 """Execute a Muse VCS reset operation.
213
214 Moves the current branch's HEAD pointer to *ref* and, for hard mode,
215 overwrites ``muse-work/`` with the target snapshot's file content.
216
217 This function is the testable async core — it performs all filesystem
218 and DB I/O. The Typer CLI wrapper in ``muse_cli/commands/reset.py``
219 handles argument parsing, user confirmation, and error display.
220
221 Raises:
222 typer.Exit: On user-facing errors (merge in progress, ref not found,
223 branch has no commits).
224 MissingObjectError: When ``--hard`` cannot find a required blob in the
225 object store.
226
227 Args:
228 root: Muse repository root (directory containing ``.muse/``).
229 session: Open async DB session.
230 ref: Commit reference string (e.g. ``HEAD~2``, ``abc123``).
231 mode: Which reset mode to apply.
232
233 Returns:
234 ``ResetResult`` describing the completed operation.
235 """
236 import typer
237 from maestro.muse_cli.errors import ExitCode
238
239 muse_dir = root / ".muse"
240
241 # ── Guard: merge in progress ─────────────────────────────────────────
242 if read_merge_state(root) is not None:
243 typer.echo(
244 "❌ Merge in progress. Resolve conflicts or abort the merge before "
245 "running muse reset."
246 )
247 raise typer.Exit(code=ExitCode.USER_ERROR)
248
249 # ── Repo identity ────────────────────────────────────────────────────
250 import json
251
252 repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text())
253 repo_id = repo_data["repo_id"]
254
255 # ── Current branch ───────────────────────────────────────────────────
256 head_ref = (muse_dir / "HEAD").read_text().strip() # "refs/heads/main"
257 branch = head_ref.rsplit("/", 1)[-1] # "main"
258 ref_path = muse_dir / pathlib.Path(head_ref)
259
260 if not ref_path.exists() or not ref_path.read_text().strip():
261 typer.echo("❌ Current branch has no commits. Nothing to reset.")
262 raise typer.Exit(code=ExitCode.USER_ERROR)
263
264 # ── Resolve target commit ─────────────────────────────────────────────
265 target_commit = await resolve_ref(session, repo_id, branch, ref)
266 if target_commit is None:
267 typer.echo(f"❌ Could not resolve ref: {ref!r}")
268 raise typer.Exit(code=ExitCode.USER_ERROR)
269
270 target_commit_id = target_commit.commit_id
271
272 # ── soft / mixed: only move the branch pointer ────────────────────────
273 if mode in (ResetMode.SOFT, ResetMode.MIXED):
274 ref_path.write_text(target_commit_id)
275 logger.info(
276 "✅ muse reset --%s: branch %r → %s",
277 mode.value,
278 branch,
279 target_commit_id[:8],
280 )
281 return ResetResult(
282 target_commit_id=target_commit_id,
283 mode=mode,
284 branch=branch,
285 )
286
287 # ── hard: restore muse-work/ from the target snapshot ────────────────
288 assert mode is ResetMode.HARD
289
290 manifest = await get_commit_snapshot_manifest(session, target_commit_id)
291 if manifest is None:
292 typer.echo(
293 f"❌ Could not load snapshot for commit {target_commit_id[:8]}. "
294 "Database may be corrupt."
295 )
296 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
297
298 # Validate all objects exist before touching the working tree.
299 for rel_path, object_id in manifest.items():
300 if not has_object(root, object_id):
301 raise MissingObjectError(object_id, rel_path)
302
303 workdir = root / "muse-work"
304 workdir.mkdir(parents=True, exist_ok=True)
305
306 # Build set of current files in muse-work/ for deletion tracking.
307 current_files: set[pathlib.Path] = {
308 f for f in workdir.rglob("*") if f.is_file() and not f.name.startswith(".")
309 }
310
311 files_restored = 0
312 target_paths: set[pathlib.Path] = set()
313
314 for rel_path, object_id in manifest.items():
315 dest = workdir / rel_path
316 restore_object(root, object_id, dest)
317 target_paths.add(dest)
318 files_restored += 1
319 logger.debug("✅ Restored %s from object %s", rel_path, object_id[:8])
320
321 # Delete files not in the target snapshot.
322 files_deleted = 0
323 for stale_file in current_files - target_paths:
324 stale_file.unlink(missing_ok=True)
325 files_deleted += 1
326 logger.debug("🗑 Deleted stale file %s", stale_file)
327
328 # Remove empty directories left after deletion.
329 for dirpath in sorted(workdir.rglob("*"), reverse=True):
330 if dirpath.is_dir() and not any(dirpath.iterdir()):
331 try:
332 dirpath.rmdir()
333 except OSError:
334 pass
335
336 # Update branch pointer last (after successful worktree restoration).
337 ref_path.write_text(target_commit_id)
338
339 logger.info(
340 "✅ muse reset --hard: branch %r → %s (%d restored, %d deleted)",
341 branch,
342 target_commit_id[:8],
343 files_restored,
344 files_deleted,
345 )
346 return ResetResult(
347 target_commit_id=target_commit_id,
348 mode=mode,
349 branch=branch,
350 files_restored=files_restored,
351 files_deleted=files_deleted,
352 )