"""workdir.py — Working-tree restoration utilities. The Muse working tree is the repository root (minus ``.muse/`` and other hidden/ignored paths tracked by ``.museignore``). These helpers surgically apply a snapshot manifest to the working tree without ever destroying the root directory itself. """ from __future__ import annotations import hashlib import logging import pathlib from muse.core.object_store import restore_object from muse.core.snapshot import walk_workdir from muse.core.validation import contain_path from muse.core._types import Manifest logger = logging.getLogger(__name__) def apply_manifest(root: pathlib.Path, target_manifest: Manifest) -> None: """Surgically apply *target_manifest* to the working tree at *root*. Unlike a wipe-and-restore approach this function: 1. Removes files currently visible to Muse that are absent from *target_manifest*. 2. Restores every file listed in *target_manifest* from the object store, overwriting any existing content. The repository root and ``.muse/`` are never deleted. Only files that were previously tracked (visible to ``walk_workdir``) are candidates for removal, so untracked files (hidden files, ignored paths) are left alone. Args: root: Repository root — the directory that contains ``.muse/``. target_manifest: Mapping of POSIX-relative paths to SHA-256 object IDs that the working tree should contain after this call. Raises: ValueError: When *target_manifest* is empty but the working tree has tracked files — this is almost certainly a programming error (e.g. a caller passed an unintentionally empty manifest after a failed store read) and would silently delete all tracked files. Callers that genuinely intend to produce an empty working tree must call ``walk_workdir`` first and handle this case explicitly. """ current_files = set(walk_workdir(root).keys()) target_files = set(target_manifest.keys()) # Last-resort data-loss guard: an empty target_manifest with a non-empty # working tree is almost never intentional. The canonical way to produce # this is compute_snapshot_id({}) → SHA-256(b"") — the data-loss sentinel. # Refuse the call so the caller's failure surfaces as an exception rather # than as mysteriously missing files. if not target_manifest and current_files: raise ValueError( f"apply_manifest called with an empty target_manifest while the " f"working tree contains {len(current_files)} tracked file(s). " "This would delete all tracked files and is almost certainly a " "programming error (e.g. an unreadable snapshot was silently treated " "as {}). Fix the root cause in the caller instead." ) for rel_posix in current_files - target_files: fp = root / pathlib.Path(rel_posix) if fp.exists(): fp.unlink() missing: list[str] = [] for rel_path, object_id in target_manifest.items(): try: safe_dest = contain_path(root, rel_path) except ValueError as exc: logger.warning("⚠️ Skipping unsafe manifest path %r: %s", rel_path, exc) continue if not restore_object(root, object_id, safe_dest): missing.append(rel_path) if missing: paths_fmt = ", ".join(repr(p) for p in missing[:5]) extra = f" … and {len(missing) - 5} more" if len(missing) > 5 else "" raise RuntimeError( f"apply_manifest: {len(missing)} object(s) missing from the local store " f"({paths_fmt}{extra}). The working tree cannot be fully restored. " "Fetch the missing objects from the remote before retrying." ) def verify_workdir_integrity( root: pathlib.Path, expected_manifest: Manifest, ) -> list[tuple[str, str, str | None]]: """Compare the working tree against *expected_manifest* byte-for-byte. Reads every file in *expected_manifest* from disk, hashes it, and checks the result against the manifest entry. Also reports tracked files that exist on disk but are not in *expected_manifest*. This is an *after-the-fact* integrity check. Call it after :func:`apply_manifest` or after a checkout/merge to confirm the working tree is coherent. The check is O(total file bytes) — use it sparingly on very large repositories. Args: root: Repository root. expected_manifest: Mapping of POSIX-relative paths to SHA-256 digests that the working tree must contain. Returns: A list of ``(rel_path, expected_hash, actual_hash_or_None)`` tuples for every mismatch. ``actual_hash_or_None`` is ``None`` when the file is absent from disk; it is the on-disk SHA-256 when the file exists but has the wrong content. An empty list means the working tree is perfectly clean. """ mismatches: list[tuple[str, str, str | None]] = [] actual = walk_workdir(root) for rel_path, expected_hash in expected_manifest.items(): actual_hash = actual.get(rel_path) if actual_hash is None: fp = root / pathlib.Path(rel_path) if fp.exists(): h = hashlib.sha256() with fp.open("rb") as fh: for chunk in iter(lambda: fh.read(65536), b""): h.update(chunk) actual_hash = h.hexdigest() if actual_hash != expected_hash: mismatches.append((rel_path, expected_hash, actual_hash)) elif actual_hash != expected_hash: mismatches.append((rel_path, expected_hash, actual_hash)) for rel_path in actual: if rel_path not in expected_manifest: mismatches.append((rel_path, "", actual[rel_path])) return mismatches