workdir.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
| 1 | """workdir.py — Working-tree restoration utilities. |
| 2 | |
| 3 | The Muse working tree is the repository root (minus ``.muse/`` and other |
| 4 | hidden/ignored paths tracked by ``.museignore``). These helpers surgically |
| 5 | apply a snapshot manifest to the working tree without ever destroying the |
| 6 | root directory itself. |
| 7 | """ |
| 8 | |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | import hashlib |
| 12 | import logging |
| 13 | import pathlib |
| 14 | |
| 15 | from muse.core.object_store import restore_object |
| 16 | from muse.core.snapshot import walk_workdir |
| 17 | from muse.core.validation import contain_path |
| 18 | from muse.core._types import Manifest |
| 19 | |
| 20 | logger = logging.getLogger(__name__) |
| 21 | |
| 22 | |
| 23 | def apply_manifest(root: pathlib.Path, target_manifest: Manifest) -> None: |
| 24 | """Surgically apply *target_manifest* to the working tree at *root*. |
| 25 | |
| 26 | Unlike a wipe-and-restore approach this function: |
| 27 | |
| 28 | 1. Removes files currently visible to Muse that are absent from |
| 29 | *target_manifest*. |
| 30 | 2. Restores every file listed in *target_manifest* from the object store, |
| 31 | overwriting any existing content. |
| 32 | |
| 33 | The repository root and ``.muse/`` are never deleted. Only files that |
| 34 | were previously tracked (visible to ``walk_workdir``) are candidates for |
| 35 | removal, so untracked files (hidden files, ignored paths) are left alone. |
| 36 | |
| 37 | Args: |
| 38 | root: Repository root — the directory that contains ``.muse/``. |
| 39 | target_manifest: Mapping of POSIX-relative paths to SHA-256 object IDs |
| 40 | that the working tree should contain after this call. |
| 41 | |
| 42 | Raises: |
| 43 | ValueError: When *target_manifest* is empty but the working tree has |
| 44 | tracked files — this is almost certainly a programming error |
| 45 | (e.g. a caller passed an unintentionally empty manifest after |
| 46 | a failed store read) and would silently delete all tracked files. |
| 47 | Callers that genuinely intend to produce an empty working tree |
| 48 | must call ``walk_workdir`` first and handle this case explicitly. |
| 49 | """ |
| 50 | current_files = set(walk_workdir(root).keys()) |
| 51 | target_files = set(target_manifest.keys()) |
| 52 | |
| 53 | # Last-resort data-loss guard: an empty target_manifest with a non-empty |
| 54 | # working tree is almost never intentional. The canonical way to produce |
| 55 | # this is compute_snapshot_id({}) → SHA-256(b"") — the data-loss sentinel. |
| 56 | # Refuse the call so the caller's failure surfaces as an exception rather |
| 57 | # than as mysteriously missing files. |
| 58 | if not target_manifest and current_files: |
| 59 | raise ValueError( |
| 60 | f"apply_manifest called with an empty target_manifest while the " |
| 61 | f"working tree contains {len(current_files)} tracked file(s). " |
| 62 | "This would delete all tracked files and is almost certainly a " |
| 63 | "programming error (e.g. an unreadable snapshot was silently treated " |
| 64 | "as {}). Fix the root cause in the caller instead." |
| 65 | ) |
| 66 | |
| 67 | for rel_posix in current_files - target_files: |
| 68 | fp = root / pathlib.Path(rel_posix) |
| 69 | if fp.exists(): |
| 70 | fp.unlink() |
| 71 | |
| 72 | missing: list[str] = [] |
| 73 | for rel_path, object_id in target_manifest.items(): |
| 74 | try: |
| 75 | safe_dest = contain_path(root, rel_path) |
| 76 | except ValueError as exc: |
| 77 | logger.warning("⚠️ Skipping unsafe manifest path %r: %s", rel_path, exc) |
| 78 | continue |
| 79 | if not restore_object(root, object_id, safe_dest): |
| 80 | missing.append(rel_path) |
| 81 | |
| 82 | if missing: |
| 83 | paths_fmt = ", ".join(repr(p) for p in missing[:5]) |
| 84 | extra = f" … and {len(missing) - 5} more" if len(missing) > 5 else "" |
| 85 | raise RuntimeError( |
| 86 | f"apply_manifest: {len(missing)} object(s) missing from the local store " |
| 87 | f"({paths_fmt}{extra}). The working tree cannot be fully restored. " |
| 88 | "Fetch the missing objects from the remote before retrying." |
| 89 | ) |
| 90 | |
| 91 | |
| 92 | def verify_workdir_integrity( |
| 93 | root: pathlib.Path, |
| 94 | expected_manifest: Manifest, |
| 95 | ) -> list[tuple[str, str, str | None]]: |
| 96 | """Compare the working tree against *expected_manifest* byte-for-byte. |
| 97 | |
| 98 | Reads every file in *expected_manifest* from disk, hashes it, and checks |
| 99 | the result against the manifest entry. Also reports tracked files that |
| 100 | exist on disk but are not in *expected_manifest*. |
| 101 | |
| 102 | This is an *after-the-fact* integrity check. Call it after |
| 103 | :func:`apply_manifest` or after a checkout/merge to confirm the working |
| 104 | tree is coherent. The check is O(total file bytes) — use it sparingly |
| 105 | on very large repositories. |
| 106 | |
| 107 | Args: |
| 108 | root: Repository root. |
| 109 | expected_manifest: Mapping of POSIX-relative paths to SHA-256 digests |
| 110 | that the working tree must contain. |
| 111 | |
| 112 | Returns: |
| 113 | A list of ``(rel_path, expected_hash, actual_hash_or_None)`` tuples |
| 114 | for every mismatch. ``actual_hash_or_None`` is ``None`` when the file |
| 115 | is absent from disk; it is the on-disk SHA-256 when the file exists |
| 116 | but has the wrong content. An empty list means the working tree is |
| 117 | perfectly clean. |
| 118 | """ |
| 119 | mismatches: list[tuple[str, str, str | None]] = [] |
| 120 | actual = walk_workdir(root) |
| 121 | |
| 122 | for rel_path, expected_hash in expected_manifest.items(): |
| 123 | actual_hash = actual.get(rel_path) |
| 124 | if actual_hash is None: |
| 125 | fp = root / pathlib.Path(rel_path) |
| 126 | if fp.exists(): |
| 127 | h = hashlib.sha256() |
| 128 | with fp.open("rb") as fh: |
| 129 | for chunk in iter(lambda: fh.read(65536), b""): |
| 130 | h.update(chunk) |
| 131 | actual_hash = h.hexdigest() |
| 132 | if actual_hash != expected_hash: |
| 133 | mismatches.append((rel_path, expected_hash, actual_hash)) |
| 134 | elif actual_hash != expected_hash: |
| 135 | mismatches.append((rel_path, expected_hash, actual_hash)) |
| 136 | |
| 137 | for rel_path in actual: |
| 138 | if rel_path not in expected_manifest: |
| 139 | mismatches.append((rel_path, "", actual[rel_path])) |
| 140 | |
| 141 | return mismatches |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
33 days ago