harmony_shelf.py
python
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