"""Bridge Harmony ↔ rerere and Shelf ↔ Stash adapters. Owns the four bidirectional bridge helpers that translate between Muse abstractions (Harmony conflict patterns, shelf entries) and Git equivalents (rerere rr-cache, stashes). """ from __future__ import annotations import hashlib import json import pathlib import subprocess from muse.core.bridge.state import _SidecarData from muse.core.paths import git_bridge_sidecar_path from muse.core.types import load_json_file, now_utc_iso, split_id def import_rerere_to_harmony( root: pathlib.Path, git_dir: pathlib.Path, *, confidence: float = 0.7, dry_run: bool = False, ) -> int: """Import git rerere conflict resolutions into Muse Harmony patterns. Walks ``.git/rr-cache/`` looking for directories that contain both a ``preimage`` file (the conflicted state) and a ``postimage`` file (the resolved state). Each pair is imported as a Harmony :class:`ConflictPattern` with a corresponding :class:`Resolution` at the given confidence level. The preimage bytes are stored in the Muse object store as the synthetic ``ours`` side. A dummy ``theirs`` id is derived from the directory name so that :func:`~muse.core.harmony.blob_fingerprint` produces a stable hash. The postimage bytes become the ``outcome_blob``. Args: root: Muse repo root (contains ``.muse/``). git_dir: Git repo root (contains ``.git/``). confidence: Confidence score assigned to imported resolutions (default 0.7). dry_run: If True, count what would be imported without writing. Returns: Number of rerere entries imported (or would-be-imported in dry_run). Raises: FileNotFoundError: If ``git_dir/.git/rr-cache/`` does not exist. """ import datetime as _dt from muse.core.object_store import write_object from muse.core.types import blob_id from muse.core.harmony import ( AgentProvenance, ConflictPattern, ConflictType, Resolution, ResolutionStrategy, blob_fingerprint as _blob_fp, compute_pattern_id, compute_resolution_id, record_pattern, save_resolution, ) rr_cache = git_dir / ".git" / "rr-cache" if not rr_cache.exists(): raise FileNotFoundError( f"git rerere cache not found at {rr_cache}. " "Run 'git rerere' in the git repo to enable rerere first." ) imported = 0 now = _dt.datetime.now(_dt.timezone.utc) for entry_dir in sorted(rr_cache.iterdir()): if not entry_dir.is_dir(): continue preimage_path = entry_dir / "preimage" postimage_path = entry_dir / "postimage" if not preimage_path.exists() or not postimage_path.exists(): continue if dry_run: imported += 1 continue preimage_bytes = preimage_path.read_bytes() postimage_bytes = postimage_path.read_bytes() conflict_path = entry_dir.name ours_id = blob_id(preimage_bytes) write_object(root, ours_id, preimage_bytes) theirs_seed = entry_dir.name.encode() theirs_id = blob_id(hashlib.sha256(theirs_seed).digest()) blob_fp = _blob_fp(ours_id, theirs_id) pattern_id = compute_pattern_id(conflict_path, blob_fp, blob_fp) pattern = ConflictPattern( pattern_id=pattern_id, path=conflict_path, domain="code", conflict_type=ConflictType.CONTENT, blob_fingerprint=blob_fp, semantic_fingerprint=blob_fp, ours_id=ours_id, theirs_id=theirs_id, description={"source": "git-rerere", "rr_cache_dir": entry_dir.name}, recorded_at=now, recorded_by="bridge:git-rerere", ) record_pattern(root, pattern) outcome_blob = blob_id(postimage_bytes) write_object(root, outcome_blob, postimage_bytes) prov = AgentProvenance.agent("bridge", "git-rerere") import datetime as _dt2 stable_at = _dt2.datetime(1970, 1, 1, tzinfo=_dt2.timezone.utc) resolution_id = compute_resolution_id( pattern_id, outcome_blob, ResolutionStrategy.EXACT_REPLAY, prov, stable_at ) resolution = Resolution( resolution_id=resolution_id, pattern_id=pattern_id, strategy=ResolutionStrategy.EXACT_REPLAY, policy_id=None, outcome_blob=outcome_blob, resolved_by=prov, human_verified=False, confidence=confidence, rationale=f"Imported from git rerere cache: {entry_dir.name}", resolved_at=now, applied_count=0, ) save_resolution(root, resolution) imported += 1 return imported def export_harmony_to_rerere( root: pathlib.Path, git_dir: pathlib.Path, *, dry_run: bool = False, ) -> int: """Export Muse Harmony resolutions to git rerere format. Reads all :class:`~muse.core.harmony.ConflictPattern` + :func:`~muse.core.harmony.best_resolution` pairs from ``.muse/harmony/patterns/`` and writes them into ``.git/rr-cache/`` as preimage/postimage file pairs that ``git rerere`` can replay. Only patterns with a human-verified or high-confidence (>= 0.8) resolution are exported. Patterns without a best resolution are skipped. Args: root: Muse repo root. git_dir: Git repo root. dry_run: If True, count what would be exported without writing. Returns: Number of Harmony patterns exported to rr-cache. """ from muse.core.harmony import best_resolution, list_patterns from muse.core.object_store import read_object rr_cache = git_dir / ".git" / "rr-cache" patterns = list_patterns(root) exported = 0 for pattern in patterns: res = best_resolution(root, pattern.pattern_id) if res is None: continue if not res.human_verified and res.confidence < 0.8: continue if dry_run: exported += 1 continue preimage_bytes = read_object(root, pattern.ours_id) outcome_bytes = read_object(root, res.outcome_blob) if preimage_bytes is None or outcome_bytes is None: continue _, pattern_hex = split_id(pattern.pattern_id) dir_name = pattern_hex[:40] entry_dir = rr_cache / dir_name entry_dir.mkdir(parents=True, exist_ok=True) (entry_dir / "preimage").write_bytes(preimage_bytes) (entry_dir / "postimage").write_bytes(outcome_bytes) exported += 1 return exported def import_stashes_to_shelf( root: pathlib.Path, git_dir: pathlib.Path, *, dry_run: bool = False, ) -> int: """Import git stashes as Muse shelf entries. Runs ``git stash list`` and for each stash reads the patched file tree then creates a Muse shelf entry with ``intent_type='handoff'`` and a message matching the stash description. Bridge state tracks which stashes have been imported to avoid duplicate shelf entries on subsequent runs. Args: root: Muse repo root. git_dir: Git repo root. dry_run: If True, count what would be imported without writing. Returns: Number of stash entries imported. """ from muse.core.object_store import write_object from muse.core.types import blob_id, content_hash from muse.core.shelf import write_shelf_entry as _write_shelf_entry sidecar_path = git_bridge_sidecar_path(root) def _read_sidecar() -> _SidecarData: return load_json_file(sidecar_path) or _SidecarData() def _write_sidecar(data: _SidecarData) -> None: sidecar_path.parent.mkdir(parents=True, exist_ok=True) sidecar_path.write_text(json.dumps(data), encoding="utf-8") sidecar = _read_sidecar() imported_stashes: list[str] = list(sidecar.get("imported_stashes", [])) try: result = subprocess.run( ["git", "-C", str(git_dir), "stash", "list", "--format=%gd|%s"], capture_output=True, text=True, check=True, ) except subprocess.CalledProcessError: return 0 stash_lines = [line for line in result.stdout.splitlines() if line.strip()] if not stash_lines: return 0 imported = 0 now_iso = now_utc_iso() for line in stash_lines: parts = line.split("|", 1) stash_ref = parts[0].strip() description = parts[1].strip() if len(parts) > 1 else stash_ref if stash_ref in imported_stashes: continue if dry_run: imported += 1 continue try: stat_result = subprocess.run( ["git", "-C", str(git_dir), "stash", "show", "--name-only", stash_ref], capture_output=True, text=True, check=True, ) except subprocess.CalledProcessError: continue changed_files = [f for f in stat_result.stdout.splitlines() if f.strip()] snapshot: dict[str, str] = {} for rel_path in changed_files: try: blob_result = subprocess.run( ["git", "-C", str(git_dir), "show", f"{stash_ref}:{rel_path}"], capture_output=True, check=True, ) blob_bytes = blob_result.stdout except subprocess.CalledProcessError: continue object_id = blob_id(blob_bytes) write_object(root, object_id, blob_bytes) snapshot[rel_path] = object_id if not snapshot: continue snapshot_id = content_hash(snapshot) entry_without_id = { "name": f"git-stash-{stash_ref.replace('@', '').replace('{', '').replace('}', '')}", "snapshot": snapshot, "deleted": [], "snapshot_id": snapshot_id, "parent_commit": "", "branch": "unknown", "created_at": now_iso, "created_by": "bridge:git-stash", "intent_type": "handoff", "intent": description, "resumable": True, "tags": ["git-stash"], "expires_at": None, "domain_state": {}, } shelf_id = content_hash(entry_without_id) entry = dict(entry_without_id) entry["id"] = shelf_id _write_shelf_entry(root, entry) imported_stashes.append(stash_ref) imported += 1 if not dry_run and imported > 0: sidecar["imported_stashes"] = imported_stashes _write_sidecar(sidecar) return imported def export_shelves_to_stash( root: pathlib.Path, git_dir: pathlib.Path, *, dry_run: bool = False, ) -> int: """Export Muse shelf entries as git stashes. Lists all Muse shelf entries and for each one that hasn't already been exported (tracked in bridge state), writes the snapshot files into a temp directory and calls ``git stash push`` to create the stash. Args: root: Muse repo root. git_dir: Git repo root. dry_run: If True, count what would be exported without writing. Returns: Number of shelf entries exported to git stash. """ import tempfile from muse.core.shelf import list_shelf_entries as _list_shelf_entries from muse.core.object_store import read_object raw_entries = _list_shelf_entries(root) if not raw_entries: return 0 sidecar_path = git_bridge_sidecar_path(root) def _read_sidecar() -> _SidecarData: return load_json_file(sidecar_path) or _SidecarData() def _write_sidecar(data: _SidecarData) -> None: sidecar_path.parent.mkdir(parents=True, exist_ok=True) sidecar_path.write_text(json.dumps(data), encoding="utf-8") sidecar = _read_sidecar() exported_ids: list[str] = list(sidecar.get("exported_shelf_ids", [])) exported = 0 for item in raw_entries: if not isinstance(item, dict): continue entry_id = str(item.get("id", "")) if entry_id in exported_ids: continue snapshot = item.get("snapshot", {}) if not isinstance(snapshot, dict) or not snapshot: continue intent = item.get("intent") or item.get("name", "muse-shelf") stash_message = f"muse-shelf: {intent}" if dry_run: exported += 1 continue with tempfile.TemporaryDirectory() as tmp_dir: tmp_path = pathlib.Path(tmp_dir) any_written = False for rel_path, object_id in snapshot.items(): if not isinstance(rel_path, str) or not isinstance(object_id, str): continue blob_bytes = read_object(root, object_id) if blob_bytes is None: continue dest = tmp_path / rel_path dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(blob_bytes) any_written = True if not any_written: continue for rel_path in snapshot: src = tmp_path / rel_path if not src.exists(): continue dest_in_git = git_dir / rel_path dest_in_git.parent.mkdir(parents=True, exist_ok=True) dest_in_git.write_bytes(src.read_bytes()) try: subprocess.run( ["git", "-C", str(git_dir), "add"] + list(snapshot.keys()), capture_output=True, check=True, ) subprocess.run( ["git", "-C", str(git_dir), "stash", "push", "-m", stash_message], capture_output=True, check=True, ) except subprocess.CalledProcessError: continue exported_ids.append(entry_id) exported += 1 if not dry_run and exported > 0: sidecar["exported_shelf_ids"] = exported_ids _write_sidecar(sidecar) return exported