gabriel / muse public
harmony_shelf.py python
442 lines 13.9 KB
Raw
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 17 hours ago
1 """Bridge Harmony ↔ rerere and Shelf ↔ Stash adapters.
2
3 Owns the four bidirectional bridge helpers that translate between Muse
4 abstractions (Harmony conflict patterns, shelf entries) and Git equivalents
5 (rerere rr-cache, stashes).
6 """
7
8 from __future__ import annotations
9
10 import hashlib
11 import json
12 import pathlib
13 import subprocess
14
15 from muse.core.bridge.state import _SidecarData
16 from muse.core.paths import git_bridge_sidecar_path
17 from muse.core.types import load_json_file, now_utc_iso, split_id
18
19
20 def import_rerere_to_harmony(
21 root: pathlib.Path,
22 git_dir: pathlib.Path,
23 *,
24 confidence: float = 0.7,
25 dry_run: bool = False,
26 ) -> int:
27 """Import git rerere conflict resolutions into Muse Harmony patterns.
28
29 Walks ``.git/rr-cache/`` looking for directories that contain both a
30 ``preimage`` file (the conflicted state) and a ``postimage`` file (the
31 resolved state). Each pair is imported as a Harmony :class:`ConflictPattern`
32 with a corresponding :class:`Resolution` at the given confidence level.
33
34 The preimage bytes are stored in the Muse object store as the synthetic
35 ``ours`` side. A dummy ``theirs`` id is derived from the directory name so
36 that :func:`~muse.core.harmony.blob_fingerprint` produces a stable hash.
37 The postimage bytes become the ``outcome_blob``.
38
39 Args:
40 root: Muse repo root (contains ``.muse/``).
41 git_dir: Git repo root (contains ``.git/``).
42 confidence: Confidence score assigned to imported resolutions (default 0.7).
43 dry_run: If True, count what would be imported without writing.
44
45 Returns:
46 Number of rerere entries imported (or would-be-imported in dry_run).
47
48 Raises:
49 FileNotFoundError: If ``git_dir/.git/rr-cache/`` does not exist.
50 """
51 import datetime as _dt
52 from muse.core.object_store import write_object
53 from muse.core.types import blob_id
54 from muse.core.harmony import (
55 AgentProvenance,
56 ConflictPattern,
57 ConflictType,
58 Resolution,
59 ResolutionStrategy,
60 blob_fingerprint as _blob_fp,
61 compute_pattern_id,
62 compute_resolution_id,
63 record_pattern,
64 save_resolution,
65 )
66
67 rr_cache = git_dir / ".git" / "rr-cache"
68 if not rr_cache.exists():
69 raise FileNotFoundError(
70 f"git rerere cache not found at {rr_cache}. "
71 "Run 'git rerere' in the git repo to enable rerere first."
72 )
73
74 imported = 0
75 now = _dt.datetime.now(_dt.timezone.utc)
76
77 for entry_dir in sorted(rr_cache.iterdir()):
78 if not entry_dir.is_dir():
79 continue
80
81 preimage_path = entry_dir / "preimage"
82 postimage_path = entry_dir / "postimage"
83
84 if not preimage_path.exists() or not postimage_path.exists():
85 continue
86
87 if dry_run:
88 imported += 1
89 continue
90
91 preimage_bytes = preimage_path.read_bytes()
92 postimage_bytes = postimage_path.read_bytes()
93
94 conflict_path = entry_dir.name
95
96 ours_id = blob_id(preimage_bytes)
97 write_object(root, ours_id, preimage_bytes)
98
99 theirs_seed = entry_dir.name.encode()
100 theirs_id = blob_id(hashlib.sha256(theirs_seed).digest())
101
102 blob_fp = _blob_fp(ours_id, theirs_id)
103 pattern_id = compute_pattern_id(conflict_path, blob_fp, blob_fp)
104
105 pattern = ConflictPattern(
106 pattern_id=pattern_id,
107 path=conflict_path,
108 domain="code",
109 conflict_type=ConflictType.CONTENT,
110 blob_fingerprint=blob_fp,
111 semantic_fingerprint=blob_fp,
112 ours_id=ours_id,
113 theirs_id=theirs_id,
114 description={"source": "git-rerere", "rr_cache_dir": entry_dir.name},
115 recorded_at=now,
116 recorded_by="bridge:git-rerere",
117 )
118 record_pattern(root, pattern)
119
120 outcome_blob = blob_id(postimage_bytes)
121 write_object(root, outcome_blob, postimage_bytes)
122
123 prov = AgentProvenance.agent("bridge", "git-rerere")
124 import datetime as _dt2
125 stable_at = _dt2.datetime(1970, 1, 1, tzinfo=_dt2.timezone.utc)
126 resolution_id = compute_resolution_id(
127 pattern_id, outcome_blob, ResolutionStrategy.EXACT_REPLAY, prov, stable_at
128 )
129 resolution = Resolution(
130 resolution_id=resolution_id,
131 pattern_id=pattern_id,
132 strategy=ResolutionStrategy.EXACT_REPLAY,
133 policy_id=None,
134 outcome_blob=outcome_blob,
135 resolved_by=prov,
136 human_verified=False,
137 confidence=confidence,
138 rationale=f"Imported from git rerere cache: {entry_dir.name}",
139 resolved_at=now,
140 applied_count=0,
141 )
142 save_resolution(root, resolution)
143 imported += 1
144
145 return imported
146
147
148 def export_harmony_to_rerere(
149 root: pathlib.Path,
150 git_dir: pathlib.Path,
151 *,
152 dry_run: bool = False,
153 ) -> int:
154 """Export Muse Harmony resolutions to git rerere format.
155
156 Reads all :class:`~muse.core.harmony.ConflictPattern` +
157 :func:`~muse.core.harmony.best_resolution` pairs from
158 ``.muse/harmony/patterns/`` and writes them into ``.git/rr-cache/`` as
159 preimage/postimage file pairs that ``git rerere`` can replay.
160
161 Only patterns with a human-verified or high-confidence (>= 0.8) resolution
162 are exported. Patterns without a best resolution are skipped.
163
164 Args:
165 root: Muse repo root.
166 git_dir: Git repo root.
167 dry_run: If True, count what would be exported without writing.
168
169 Returns:
170 Number of Harmony patterns exported to rr-cache.
171 """
172 from muse.core.harmony import best_resolution, list_patterns
173 from muse.core.object_store import read_object
174
175 rr_cache = git_dir / ".git" / "rr-cache"
176
177 patterns = list_patterns(root)
178 exported = 0
179
180 for pattern in patterns:
181 res = best_resolution(root, pattern.pattern_id)
182 if res is None:
183 continue
184 if not res.human_verified and res.confidence < 0.8:
185 continue
186
187 if dry_run:
188 exported += 1
189 continue
190
191 preimage_bytes = read_object(root, pattern.ours_id)
192 outcome_bytes = read_object(root, res.outcome_blob)
193
194 if preimage_bytes is None or outcome_bytes is None:
195 continue
196
197 _, pattern_hex = split_id(pattern.pattern_id)
198 dir_name = pattern_hex[:40]
199 entry_dir = rr_cache / dir_name
200 entry_dir.mkdir(parents=True, exist_ok=True)
201
202 (entry_dir / "preimage").write_bytes(preimage_bytes)
203 (entry_dir / "postimage").write_bytes(outcome_bytes)
204 exported += 1
205
206 return exported
207
208
209 def import_stashes_to_shelf(
210 root: pathlib.Path,
211 git_dir: pathlib.Path,
212 *,
213 dry_run: bool = False,
214 ) -> int:
215 """Import git stashes as Muse shelf entries.
216
217 Runs ``git stash list`` and for each stash reads the patched file tree
218 then creates a Muse shelf entry with ``intent_type='handoff'`` and a
219 message matching the stash description.
220
221 Bridge state tracks which stashes have been imported to avoid duplicate
222 shelf entries on subsequent runs.
223
224 Args:
225 root: Muse repo root.
226 git_dir: Git repo root.
227 dry_run: If True, count what would be imported without writing.
228
229 Returns:
230 Number of stash entries imported.
231 """
232 from muse.core.object_store import write_object
233 from muse.core.types import blob_id, content_hash
234 from muse.core.shelf import write_shelf_entry as _write_shelf_entry
235
236 sidecar_path = git_bridge_sidecar_path(root)
237
238 def _read_sidecar() -> _SidecarData:
239 return load_json_file(sidecar_path) or _SidecarData()
240
241 def _write_sidecar(data: _SidecarData) -> None:
242 sidecar_path.parent.mkdir(parents=True, exist_ok=True)
243 sidecar_path.write_text(json.dumps(data), encoding="utf-8")
244
245 sidecar = _read_sidecar()
246 imported_stashes: list[str] = list(sidecar.get("imported_stashes", []))
247
248 try:
249 result = subprocess.run(
250 ["git", "-C", str(git_dir), "stash", "list", "--format=%gd|%s"],
251 capture_output=True, text=True, check=True,
252 )
253 except subprocess.CalledProcessError:
254 return 0
255
256 stash_lines = [line for line in result.stdout.splitlines() if line.strip()]
257 if not stash_lines:
258 return 0
259
260 imported = 0
261 now_iso = now_utc_iso()
262
263 for line in stash_lines:
264 parts = line.split("|", 1)
265 stash_ref = parts[0].strip()
266 description = parts[1].strip() if len(parts) > 1 else stash_ref
267
268 if stash_ref in imported_stashes:
269 continue
270
271 if dry_run:
272 imported += 1
273 continue
274
275 try:
276 stat_result = subprocess.run(
277 ["git", "-C", str(git_dir), "stash", "show", "--name-only", stash_ref],
278 capture_output=True, text=True, check=True,
279 )
280 except subprocess.CalledProcessError:
281 continue
282
283 changed_files = [f for f in stat_result.stdout.splitlines() if f.strip()]
284
285 snapshot: dict[str, str] = {}
286 for rel_path in changed_files:
287 try:
288 blob_result = subprocess.run(
289 ["git", "-C", str(git_dir), "show", f"{stash_ref}:{rel_path}"],
290 capture_output=True, check=True,
291 )
292 blob_bytes = blob_result.stdout
293 except subprocess.CalledProcessError:
294 continue
295
296 object_id = blob_id(blob_bytes)
297 write_object(root, object_id, blob_bytes)
298 snapshot[rel_path] = object_id
299
300 if not snapshot:
301 continue
302
303 snapshot_id = content_hash(snapshot)
304
305 entry_without_id = {
306 "name": f"git-stash-{stash_ref.replace('@', '').replace('{', '').replace('}', '')}",
307 "snapshot": snapshot,
308 "deleted": [],
309 "snapshot_id": snapshot_id,
310 "parent_commit": "",
311 "branch": "unknown",
312 "created_at": now_iso,
313 "created_by": "bridge:git-stash",
314 "intent_type": "handoff",
315 "intent": description,
316 "resumable": True,
317 "tags": ["git-stash"],
318 "expires_at": None,
319 "domain_state": {},
320 }
321 shelf_id = content_hash(entry_without_id)
322 entry = dict(entry_without_id)
323 entry["id"] = shelf_id
324 _write_shelf_entry(root, entry)
325
326 imported_stashes.append(stash_ref)
327 imported += 1
328
329 if not dry_run and imported > 0:
330 sidecar["imported_stashes"] = imported_stashes
331 _write_sidecar(sidecar)
332
333 return imported
334
335
336 def export_shelves_to_stash(
337 root: pathlib.Path,
338 git_dir: pathlib.Path,
339 *,
340 dry_run: bool = False,
341 ) -> int:
342 """Export Muse shelf entries as git stashes.
343
344 Lists all Muse shelf entries and for each one that hasn't already been
345 exported (tracked in bridge state), writes the snapshot files into a temp
346 directory and calls ``git stash push`` to create the stash.
347
348 Args:
349 root: Muse repo root.
350 git_dir: Git repo root.
351 dry_run: If True, count what would be exported without writing.
352
353 Returns:
354 Number of shelf entries exported to git stash.
355 """
356 import tempfile
357 from muse.core.shelf import list_shelf_entries as _list_shelf_entries
358 from muse.core.object_store import read_object
359
360 raw_entries = _list_shelf_entries(root)
361 if not raw_entries:
362 return 0
363
364 sidecar_path = git_bridge_sidecar_path(root)
365
366 def _read_sidecar() -> _SidecarData:
367 return load_json_file(sidecar_path) or _SidecarData()
368
369 def _write_sidecar(data: _SidecarData) -> None:
370 sidecar_path.parent.mkdir(parents=True, exist_ok=True)
371 sidecar_path.write_text(json.dumps(data), encoding="utf-8")
372
373 sidecar = _read_sidecar()
374 exported_ids: list[str] = list(sidecar.get("exported_shelf_ids", []))
375
376 exported = 0
377
378 for item in raw_entries:
379 if not isinstance(item, dict):
380 continue
381
382 entry_id = str(item.get("id", ""))
383 if entry_id in exported_ids:
384 continue
385
386 snapshot = item.get("snapshot", {})
387 if not isinstance(snapshot, dict) or not snapshot:
388 continue
389
390 intent = item.get("intent") or item.get("name", "muse-shelf")
391 stash_message = f"muse-shelf: {intent}"
392
393 if dry_run:
394 exported += 1
395 continue
396
397 with tempfile.TemporaryDirectory() as tmp_dir:
398 tmp_path = pathlib.Path(tmp_dir)
399
400 any_written = False
401 for rel_path, object_id in snapshot.items():
402 if not isinstance(rel_path, str) or not isinstance(object_id, str):
403 continue
404 blob_bytes = read_object(root, object_id)
405 if blob_bytes is None:
406 continue
407 dest = tmp_path / rel_path
408 dest.parent.mkdir(parents=True, exist_ok=True)
409 dest.write_bytes(blob_bytes)
410 any_written = True
411
412 if not any_written:
413 continue
414
415 for rel_path in snapshot:
416 src = tmp_path / rel_path
417 if not src.exists():
418 continue
419 dest_in_git = git_dir / rel_path
420 dest_in_git.parent.mkdir(parents=True, exist_ok=True)
421 dest_in_git.write_bytes(src.read_bytes())
422
423 try:
424 subprocess.run(
425 ["git", "-C", str(git_dir), "add"] + list(snapshot.keys()),
426 capture_output=True, check=True,
427 )
428 subprocess.run(
429 ["git", "-C", str(git_dir), "stash", "push", "-m", stash_message],
430 capture_output=True, check=True,
431 )
432 except subprocess.CalledProcessError:
433 continue
434
435 exported_ids.append(entry_id)
436 exported += 1
437
438 if not dry_run and exported > 0:
439 sidecar["exported_shelf_ids"] = exported_ids
440 _write_sidecar(sidecar)
441
442 return exported
File History 1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 17 hours ago