test_merge_data_integrity.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
| 1 | """Data-integrity stress tests for the entire muse merge code path. |
| 2 | |
| 3 | Root cause of the data-loss incident |
| 4 | ------------------------------------- |
| 5 | ``muse merge`` silently defaulted every unreadable snapshot to ``{}`` via |
| 6 | ``get_head_snapshot_manifest(...) or {}``. When a snapshot file was missing |
| 7 | or in the wrong format (e.g. JSON after a msgpack migration), all three |
| 8 | manifests (base/ours/theirs) resolved to ``{}``. This caused: |
| 9 | |
| 10 | 1. ``apply_merge({}, {}, {}, …)`` → ``{}`` |
| 11 | 2. ``compute_snapshot_id({})`` → SHA-256 of ``b""`` = ``e3b0c44…`` |
| 12 | 3. ``_restore_from_manifest(root, {})`` → ``apply_manifest(root, {})`` → ALL |
| 13 | tracked files deleted. |
| 14 | |
| 15 | The fix is dual-layered: |
| 16 | |
| 17 | * **merge.py**: every snapshot read is now a hard fail — ``None`` returns |
| 18 | abort the merge with an error before any manifest is applied to the tree. |
| 19 | * **merge.py**: before ``_restore_from_manifest`` is called, the merged |
| 20 | manifest is validated; applying an empty result to a non-empty working |
| 21 | tree is rejected. |
| 22 | |
| 23 | Test categories |
| 24 | --------------- |
| 25 | I Sentinel-value unit tests (document the dangerous constants). |
| 26 | II Store-read-failure guard tests (missing/corrupt snapshot → abort). |
| 27 | III Empty-manifest guard (merged result empty despite non-empty inputs → abort). |
| 28 | IV Working-tree integrity (full CLI round-trip — count files, verify content). |
| 29 | V apply_manifest safety (workdir.py layer). |
| 30 | VI The exact regression scenario (format-migration topology). |
| 31 | VII Stress tests (100-file repos, repeated merges, diamond DAGs). |
| 32 | """ |
| 33 | from __future__ import annotations |
| 34 | |
| 35 | import datetime |
| 36 | import hashlib |
| 37 | import json |
| 38 | import pathlib |
| 39 | import uuid |
| 40 | |
| 41 | import msgpack |
| 42 | import pytest |
| 43 | from tests.cli_test_helper import CliRunner |
| 44 | from muse.core._types import Manifest |
| 45 | |
| 46 | type _EnvMap = dict[str, str] |
| 47 | |
| 48 | runner = CliRunner() |
| 49 | cli = None # CliRunner ignores this positional arg |
| 50 | |
| 51 | # SHA-256 of b"" — the sentinel produced by compute_snapshot_id({}) |
| 52 | _SHA256_EMPTY = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" |
| 53 | |
| 54 | |
| 55 | # --------------------------------------------------------------------------- |
| 56 | # Repo helpers (mirror test_stress_merge_regression.py conventions) |
| 57 | # --------------------------------------------------------------------------- |
| 58 | |
| 59 | |
| 60 | def _h(label: str) -> str: |
| 61 | """Stable fake content hash for a label string.""" |
| 62 | return hashlib.sha256(label.encode()).hexdigest() |
| 63 | |
| 64 | |
| 65 | def _env(root: pathlib.Path) -> _EnvMap: |
| 66 | return {"MUSE_REPO_ROOT": str(root)} |
| 67 | |
| 68 | |
| 69 | def _run(root: pathlib.Path, *args: str) -> tuple[int, str]: |
| 70 | """Run a muse CLI command, auto-injecting ``--force`` for merge calls.""" |
| 71 | final_args = list(args) |
| 72 | if final_args and final_args[0] == "merge" and "--force" not in final_args: |
| 73 | final_args.insert(1, "--force") |
| 74 | result = runner.invoke(cli, final_args, env=_env(root), catch_exceptions=False) |
| 75 | return result.exit_code, result.output |
| 76 | |
| 77 | |
| 78 | def _run_unchecked(root: pathlib.Path, *args: str) -> tuple[int, str]: |
| 79 | final_args = list(args) |
| 80 | if final_args and final_args[0] == "merge" and "--force" not in final_args: |
| 81 | final_args.insert(1, "--force") |
| 82 | result = runner.invoke(cli, final_args, env=_env(root)) |
| 83 | return result.exit_code, result.output |
| 84 | |
| 85 | |
| 86 | def _write_object(root: pathlib.Path, content: bytes) -> str: |
| 87 | obj_id = hashlib.sha256(content).hexdigest() |
| 88 | obj_path = root / ".muse" / "objects" / obj_id[:2] / obj_id[2:] |
| 89 | obj_path.parent.mkdir(parents=True, exist_ok=True) |
| 90 | obj_path.write_bytes(content) |
| 91 | return obj_id |
| 92 | |
| 93 | |
| 94 | def _write_file(root: pathlib.Path, content: str) -> str: |
| 95 | return _write_object(root, content.encode()) |
| 96 | |
| 97 | |
| 98 | def _init_repo(tmp_path: pathlib.Path, domain: str = "code") -> tuple[pathlib.Path, str]: |
| 99 | """Initialise a minimal repo and return (root, repo_id).""" |
| 100 | muse_dir = tmp_path / ".muse" |
| 101 | muse_dir.mkdir() |
| 102 | repo_id = str(uuid.uuid4()) |
| 103 | (muse_dir / "repo.json").write_text(json.dumps({ |
| 104 | "repo_id": repo_id, |
| 105 | "domain": domain, |
| 106 | "default_branch": "main", |
| 107 | "created_at": "2025-01-01T00:00:00+00:00", |
| 108 | }), encoding="utf-8") |
| 109 | (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") |
| 110 | (muse_dir / "refs" / "heads").mkdir(parents=True) |
| 111 | (muse_dir / "snapshots").mkdir() |
| 112 | (muse_dir / "commits").mkdir() |
| 113 | (muse_dir / "objects").mkdir() |
| 114 | return tmp_path, repo_id |
| 115 | |
| 116 | |
| 117 | def _make_commit( |
| 118 | root: pathlib.Path, |
| 119 | repo_id: str, |
| 120 | branch: str = "main", |
| 121 | message: str = "test", |
| 122 | manifest: Manifest | None = None, |
| 123 | parent_commit_id: str | None = None, |
| 124 | parent2_commit_id: str | None = None, |
| 125 | ) -> str: |
| 126 | """Write a snapshot + commit record, advance the branch ref, return commit_id.""" |
| 127 | from muse.core.snapshot import compute_commit_id, compute_snapshot_id |
| 128 | from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot |
| 129 | |
| 130 | ref_file = root / ".muse" / "refs" / "heads" / branch |
| 131 | if parent_commit_id is None and ref_file.exists(): |
| 132 | parent_commit_id = ref_file.read_text().strip() or None |
| 133 | |
| 134 | m = manifest or {} |
| 135 | snap_id = compute_snapshot_id(m) |
| 136 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 137 | parent_ids: list[str] = [] |
| 138 | if parent_commit_id: |
| 139 | parent_ids.append(parent_commit_id) |
| 140 | if parent2_commit_id: |
| 141 | parent_ids.append(parent2_commit_id) |
| 142 | commit_id = compute_commit_id( |
| 143 | parent_ids=parent_ids, |
| 144 | snapshot_id=snap_id, |
| 145 | message=message, |
| 146 | committed_at_iso=committed_at.isoformat(), |
| 147 | ) |
| 148 | write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m)) |
| 149 | write_commit(root, CommitRecord( |
| 150 | commit_id=commit_id, |
| 151 | repo_id=repo_id, |
| 152 | branch=branch, |
| 153 | snapshot_id=snap_id, |
| 154 | message=message, |
| 155 | committed_at=committed_at, |
| 156 | parent_commit_id=parent_commit_id, |
| 157 | parent2_commit_id=parent2_commit_id, |
| 158 | )) |
| 159 | ref_file.parent.mkdir(parents=True, exist_ok=True) |
| 160 | ref_file.write_text(commit_id, encoding="utf-8") |
| 161 | return commit_id |
| 162 | |
| 163 | |
| 164 | def _ref(root: pathlib.Path, branch: str) -> str: |
| 165 | return (root / ".muse" / "refs" / "heads" / branch).read_text(encoding="utf-8").strip() |
| 166 | |
| 167 | |
| 168 | def _head_manifest(root: pathlib.Path, branch: str) -> _EnvMap: |
| 169 | """Return the snapshot manifest for *branch* HEAD.""" |
| 170 | from muse.core.store import read_commit, read_snapshot |
| 171 | commit = read_commit(root, _ref(root, branch)) |
| 172 | assert commit is not None, f"No commit on branch {branch}" |
| 173 | snap = read_snapshot(root, commit.snapshot_id) |
| 174 | assert snap is not None, f"No snapshot for {commit.snapshot_id[:8]}" |
| 175 | return snap.manifest |
| 176 | |
| 177 | |
| 178 | def _snapshot_path(root: pathlib.Path, snap_id: str) -> pathlib.Path: |
| 179 | return root / ".muse" / "snapshots" / f"{snap_id}.msgpack" |
| 180 | |
| 181 | |
| 182 | def _commit_path_for(root: pathlib.Path, commit_id: str) -> pathlib.Path: |
| 183 | return root / ".muse" / "commits" / f"{commit_id}.msgpack" |
| 184 | |
| 185 | |
| 186 | # --------------------------------------------------------------------------- |
| 187 | # Category I — Sentinel-value unit tests |
| 188 | # --------------------------------------------------------------------------- |
| 189 | |
| 190 | |
| 191 | class TestSentinelValuesI: |
| 192 | """Document the dangerous constants that signal a broken merge.""" |
| 193 | |
| 194 | def test_I1_compute_snapshot_id_empty_dict_produces_known_sha256(self) -> None: |
| 195 | """I1: compute_snapshot_id({}) == SHA-256 of b'' — the data-loss sentinel. |
| 196 | |
| 197 | If this value ever appears as a committed snapshot_id, every tracked |
| 198 | file was deleted. This test documents the constant so future readers |
| 199 | know exactly what to look for. |
| 200 | """ |
| 201 | from muse.core.snapshot import compute_snapshot_id |
| 202 | assert compute_snapshot_id({}) == _SHA256_EMPTY |
| 203 | |
| 204 | def test_I2_apply_merge_with_all_empty_inputs_returns_empty(self) -> None: |
| 205 | """I2: apply_merge({}, {}, {}, ∅, ∅, ∅) → {} — documents the dangerous passthrough.""" |
| 206 | from muse.core.merge_engine import apply_merge |
| 207 | result = apply_merge({}, {}, {}, set(), set(), set()) |
| 208 | assert result == {} |
| 209 | |
| 210 | def test_I3_apply_manifest_with_empty_target_deletes_all_tracked_files( |
| 211 | self, tmp_path: pathlib.Path |
| 212 | ) -> None: |
| 213 | """I3: apply_manifest(root, {}) removes every tracked file. |
| 214 | |
| 215 | This is the third domino in the data-loss chain. The guard in merge.py |
| 216 | must prevent apply_manifest from ever being called with an empty target |
| 217 | when the inputs were non-empty. |
| 218 | """ |
| 219 | from muse.core.workdir import apply_manifest |
| 220 | |
| 221 | root, repo_id = _init_repo(tmp_path) |
| 222 | # Write real files to the working tree and object store. |
| 223 | for i in range(5): |
| 224 | content = f"file_{i} = True\n".encode() |
| 225 | oid = hashlib.sha256(content).hexdigest() |
| 226 | (root / ".muse" / "objects" / oid[:2]).mkdir(parents=True, exist_ok=True) |
| 227 | (root / ".muse" / "objects" / oid[:2] / oid[2:]).write_bytes(content) |
| 228 | (root / f"file_{i}.py").write_bytes(content) |
| 229 | |
| 230 | files_before = list(root.glob("file_*.py")) |
| 231 | assert len(files_before) == 5 |
| 232 | |
| 233 | # apply_manifest({}) should raise a ValueError guard, not silently delete. |
| 234 | # After the fix: this raises ValueError because we're about to delete 5 files. |
| 235 | # Before the fix: it silently deletes them. |
| 236 | try: |
| 237 | apply_manifest(root, {}) |
| 238 | # If no exception, the guard is not in apply_manifest — check the caller-level guard. |
| 239 | # Either way, document the result. |
| 240 | files_after = list(root.glob("file_*.py")) |
| 241 | # The test passes whether the guard is in apply_manifest or merge.py; |
| 242 | # we just document what happened so the assertion failure is meaningful. |
| 243 | if len(files_after) == 0: |
| 244 | pytest.fail( |
| 245 | "apply_manifest(root, {}) deleted all 5 files — no guard is in place. " |
| 246 | "The guard must be added to prevent callers from accidentally deleting everything." |
| 247 | ) |
| 248 | except (ValueError, SystemExit): |
| 249 | # Guard fired correctly — apply_manifest refused to delete all files. |
| 250 | pass |
| 251 | |
| 252 | def test_I4_diff_snapshots_both_empty_returns_empty_set(self) -> None: |
| 253 | """I4: diff_snapshots({}, {}) == set() — no phantom changes.""" |
| 254 | from muse.core.merge_engine import diff_snapshots |
| 255 | assert diff_snapshots({}, {}) == set() |
| 256 | |
| 257 | def test_I5_detect_conflicts_both_empty_returns_empty(self) -> None: |
| 258 | """I5: detect_conflicts(set(), set(), {}, {}) == set().""" |
| 259 | from muse.core.merge_engine import detect_conflicts |
| 260 | assert detect_conflicts(set(), set(), {}, {}) == set() |
| 261 | |
| 262 | |
| 263 | # --------------------------------------------------------------------------- |
| 264 | # Category II — Store-read-failure guard tests |
| 265 | # --------------------------------------------------------------------------- |
| 266 | |
| 267 | |
| 268 | class TestStoreReadFailureGuardII: |
| 269 | """merge.py must abort with an error when any required snapshot is unreadable. |
| 270 | |
| 271 | None of these cases should silently fall back to {} and proceed to delete files. |
| 272 | """ |
| 273 | |
| 274 | def _setup_two_branch_repo( |
| 275 | self, tmp_path: pathlib.Path |
| 276 | ) -> tuple[pathlib.Path, str, str, str, str]: |
| 277 | """Set up a simple diverged repo: main and feat both have commits. |
| 278 | |
| 279 | Returns (root, repo_id, main_commit_id, feat_commit_id, base_commit_id). |
| 280 | """ |
| 281 | root, repo_id = _init_repo(tmp_path) |
| 282 | f0 = _write_file(root, "base.py = True\n") |
| 283 | base_c = _make_commit(root, repo_id, "main", "base", {"base.py": f0}) |
| 284 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 285 | |
| 286 | f1 = _write_file(root, "main_only.py = True\n") |
| 287 | main_c = _make_commit(root, repo_id, "main", "main change", {"base.py": f0, "main_only.py": f1}) |
| 288 | |
| 289 | f2 = _write_file(root, "feat_only.py = True\n") |
| 290 | feat_c = _make_commit(root, repo_id, "feat", "feat change", {"base.py": f0, "feat_only.py": f2}) |
| 291 | |
| 292 | return root, repo_id, main_c, feat_c, base_c |
| 293 | |
| 294 | def test_II1_missing_ours_snapshot_aborts_merge(self, tmp_path: pathlib.Path) -> None: |
| 295 | """II1: if ours (main) snapshot file is deleted, merge must abort — not delete all files.""" |
| 296 | root, repo_id, main_c, feat_c, base_c = self._setup_two_branch_repo(tmp_path) |
| 297 | |
| 298 | from muse.core.store import read_commit |
| 299 | commit = read_commit(root, main_c) |
| 300 | assert commit is not None |
| 301 | snap_path = _snapshot_path(root, commit.snapshot_id) |
| 302 | snap_path.unlink() # Delete the snapshot file |
| 303 | |
| 304 | # Attempt the merge — must fail, not succeed with empty snapshot. |
| 305 | code, out = _run_unchecked(root, "merge", "feat") |
| 306 | assert code != 0, ( |
| 307 | "REGRESSION: merge succeeded despite ours snapshot being missing. " |
| 308 | "Expected abort to prevent data loss.\nOutput: " + out |
| 309 | ) |
| 310 | # Verify main HEAD has NOT advanced past main_c. |
| 311 | assert _ref(root, "main") == main_c, ( |
| 312 | "REGRESSION: main HEAD advanced after a merge that should have aborted." |
| 313 | ) |
| 314 | |
| 315 | def test_II2_missing_theirs_snapshot_aborts_merge(self, tmp_path: pathlib.Path) -> None: |
| 316 | """II2: if theirs (feat) snapshot file is deleted, merge must abort.""" |
| 317 | root, repo_id, main_c, feat_c, base_c = self._setup_two_branch_repo(tmp_path) |
| 318 | |
| 319 | from muse.core.store import read_commit |
| 320 | commit = read_commit(root, feat_c) |
| 321 | assert commit is not None |
| 322 | snap_path = _snapshot_path(root, commit.snapshot_id) |
| 323 | snap_path.unlink() |
| 324 | |
| 325 | code, out = _run_unchecked(root, "merge", "feat") |
| 326 | assert code != 0, ( |
| 327 | "REGRESSION: merge succeeded despite theirs snapshot being missing.\nOutput: " + out |
| 328 | ) |
| 329 | assert _ref(root, "main") == main_c |
| 330 | |
| 331 | def test_II3_corrupt_ours_snapshot_aborts_merge(self, tmp_path: pathlib.Path) -> None: |
| 332 | """II3: corrupt ours snapshot (invalid msgpack) must abort merge.""" |
| 333 | root, repo_id, main_c, feat_c, base_c = self._setup_two_branch_repo(tmp_path) |
| 334 | |
| 335 | from muse.core.store import read_commit |
| 336 | commit = read_commit(root, main_c) |
| 337 | assert commit is not None |
| 338 | snap_path = _snapshot_path(root, commit.snapshot_id) |
| 339 | snap_path.write_bytes(b"\xff\xfe invalid msgpack garbage") |
| 340 | |
| 341 | code, out = _run_unchecked(root, "merge", "feat") |
| 342 | assert code != 0, ( |
| 343 | "REGRESSION: merge succeeded with corrupt ours snapshot.\nOutput: " + out |
| 344 | ) |
| 345 | assert _ref(root, "main") == main_c |
| 346 | |
| 347 | def test_II4_corrupt_theirs_snapshot_aborts_merge(self, tmp_path: pathlib.Path) -> None: |
| 348 | """II4: corrupt theirs snapshot must abort merge.""" |
| 349 | root, repo_id, main_c, feat_c, base_c = self._setup_two_branch_repo(tmp_path) |
| 350 | |
| 351 | from muse.core.store import read_commit |
| 352 | commit = read_commit(root, feat_c) |
| 353 | assert commit is not None |
| 354 | snap_path = _snapshot_path(root, commit.snapshot_id) |
| 355 | snap_path.write_bytes(b"\x00\x01\x02 also garbage") |
| 356 | |
| 357 | code, out = _run_unchecked(root, "merge", "feat") |
| 358 | assert code != 0, ( |
| 359 | "REGRESSION: merge succeeded with corrupt theirs snapshot.\nOutput: " + out |
| 360 | ) |
| 361 | assert _ref(root, "main") == main_c |
| 362 | |
| 363 | def test_II5_missing_base_snapshot_aborts_merge(self, tmp_path: pathlib.Path) -> None: |
| 364 | """II5: if the merge-base snapshot is missing, merge must abort — not treat base as {}.""" |
| 365 | root, repo_id, main_c, feat_c, base_c = self._setup_two_branch_repo(tmp_path) |
| 366 | |
| 367 | # Delete the base snapshot. |
| 368 | from muse.core.store import read_commit |
| 369 | base_commit = read_commit(root, base_c) |
| 370 | assert base_commit is not None |
| 371 | snap_path = _snapshot_path(root, base_commit.snapshot_id) |
| 372 | snap_path.unlink() |
| 373 | |
| 374 | code, out = _run_unchecked(root, "merge", "feat") |
| 375 | assert code != 0, ( |
| 376 | "REGRESSION: merge succeeded with missing base snapshot — " |
| 377 | "treating base as {} inflates change-sets and may corrupt the merge.\n" |
| 378 | "Output: " + out |
| 379 | ) |
| 380 | assert _ref(root, "main") == main_c |
| 381 | |
| 382 | def test_II6_missing_ours_commit_file_aborts_merge(self, tmp_path: pathlib.Path) -> None: |
| 383 | """II6: if the ours commit file is deleted, merge must abort.""" |
| 384 | root, repo_id, main_c, feat_c, base_c = self._setup_two_branch_repo(tmp_path) |
| 385 | |
| 386 | commit_path = _commit_path_for(root, main_c) |
| 387 | commit_path.unlink() |
| 388 | |
| 389 | code, out = _run_unchecked(root, "merge", "feat") |
| 390 | assert code != 0, ( |
| 391 | "REGRESSION: merge succeeded with missing ours commit file.\nOutput: " + out |
| 392 | ) |
| 393 | |
| 394 | def test_II7_fast_forward_missing_theirs_snapshot_aborts(self, tmp_path: pathlib.Path) -> None: |
| 395 | """II7: fast-forward merge with missing theirs snapshot must abort — not delete all files. |
| 396 | |
| 397 | Before the fix: ff_manifest defaults to {}, _restore_from_manifest({}) deletes everything. |
| 398 | After the fix: abort with an error before touching the working tree. |
| 399 | """ |
| 400 | root, repo_id = _init_repo(tmp_path) |
| 401 | |
| 402 | # Write real files to the working tree. |
| 403 | f0 = _write_file(root, "keeper.py = 42\n") |
| 404 | (root / "keeper.py").write_bytes(b"keeper.py = 42\n") |
| 405 | |
| 406 | base_c = _make_commit(root, repo_id, "main", "base", {"keeper.py": f0}) |
| 407 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 408 | |
| 409 | f1 = _write_file(root, "new.py = True\n") |
| 410 | feat_c = _make_commit(root, repo_id, "feat", "feat commit", {"keeper.py": f0, "new.py": f1}) |
| 411 | |
| 412 | # Delete feat's snapshot — main is behind feat (fast-forward case). |
| 413 | from muse.core.store import read_commit |
| 414 | feat_commit = read_commit(root, feat_c) |
| 415 | assert feat_commit is not None |
| 416 | _snapshot_path(root, feat_commit.snapshot_id).unlink() |
| 417 | |
| 418 | code, out = _run_unchecked(root, "merge", "feat") |
| 419 | assert code != 0, ( |
| 420 | "REGRESSION: fast-forward merge succeeded despite theirs snapshot missing. " |
| 421 | "This would have applied apply_manifest({}) and deleted keeper.py.\nOutput: " + out |
| 422 | ) |
| 423 | # The critical assertion: keeper.py must still exist. |
| 424 | assert (root / "keeper.py").exists(), ( |
| 425 | "DATA LOSS: keeper.py was deleted when theirs snapshot was missing " |
| 426 | "during fast-forward. The guard must abort BEFORE apply_manifest." |
| 427 | ) |
| 428 | |
| 429 | def test_II8_json_format_snapshot_treated_as_corrupt_by_msgpack_reader( |
| 430 | self, tmp_path: pathlib.Path |
| 431 | ) -> None: |
| 432 | """II8: snapshot stored in old JSON format is unreadable by msgpack reader → abort. |
| 433 | |
| 434 | This is the exact format-migration scenario that caused the data-loss incident. |
| 435 | A snapshot file written as JSON (old format) cannot be parsed by _read_msgpack |
| 436 | (new format), causing read_snapshot to return None, which then silently falls |
| 437 | back to {} — leading to data loss. |
| 438 | |
| 439 | After the fix: merge aborts rather than proceeding with an empty manifest. |
| 440 | """ |
| 441 | root, repo_id, main_c, feat_c, base_c = self._setup_two_branch_repo(tmp_path) |
| 442 | |
| 443 | from muse.core.store import read_commit |
| 444 | commit = read_commit(root, main_c) |
| 445 | assert commit is not None |
| 446 | snap_path = _snapshot_path(root, commit.snapshot_id) |
| 447 | |
| 448 | # Overwrite the msgpack snapshot with the equivalent JSON (old format). |
| 449 | # msgpack.unpackb will raise an exception on this, causing read_snapshot → None. |
| 450 | old_json = json.dumps({"snapshot_id": commit.snapshot_id, "manifest": {}}).encode() |
| 451 | snap_path.write_bytes(old_json) |
| 452 | |
| 453 | code, out = _run_unchecked(root, "merge", "feat") |
| 454 | assert code != 0, ( |
| 455 | "REGRESSION: merge succeeded when ours snapshot was in JSON (old format). " |
| 456 | "This is the exact scenario that caused the data-loss incident.\n" |
| 457 | "Expected: abort. Got: " + out |
| 458 | ) |
| 459 | assert _ref(root, "main") == main_c |
| 460 | |
| 461 | def test_II9_missing_ours_snapshot_does_not_delete_working_tree( |
| 462 | self, tmp_path: pathlib.Path |
| 463 | ) -> None: |
| 464 | """II9: working-tree files must survive when ours snapshot is unreadable. |
| 465 | |
| 466 | Belt-and-suspenders: even if the merge somehow proceeds, it must not |
| 467 | apply an empty manifest to the working tree. |
| 468 | """ |
| 469 | root, repo_id = _init_repo(tmp_path) |
| 470 | |
| 471 | # Write 10 files to the working tree. |
| 472 | manifest: Manifest = {} |
| 473 | for i in range(10): |
| 474 | content = f"module_{i} = True\n".encode() |
| 475 | oid = _write_object(root, content) |
| 476 | (root / f"module_{i}.py").write_bytes(content) |
| 477 | manifest[f"module_{i}.py"] = oid |
| 478 | |
| 479 | base_c = _make_commit(root, repo_id, "main", "base", manifest) |
| 480 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 481 | |
| 482 | extra = _write_file(root, "extra.py = True\n") |
| 483 | feat_c = _make_commit(root, repo_id, "feat", "feat", |
| 484 | {**manifest, "extra.py": extra}) |
| 485 | |
| 486 | # Advance main past base so this is a three-way merge. |
| 487 | bump = _write_file(root, "bump.py = True\n") |
| 488 | _make_commit(root, repo_id, "main", "main advance", |
| 489 | {**manifest, "bump.py": bump}) |
| 490 | |
| 491 | # Delete main's latest snapshot. |
| 492 | from muse.core.store import read_commit |
| 493 | main_commit = read_commit(root, _ref(root, "main")) |
| 494 | assert main_commit is not None |
| 495 | _snapshot_path(root, main_commit.snapshot_id).unlink() |
| 496 | |
| 497 | _run_unchecked(root, "merge", "feat") |
| 498 | |
| 499 | # All 10 original files must still exist. |
| 500 | for i in range(10): |
| 501 | assert (root / f"module_{i}.py").exists(), ( |
| 502 | f"DATA LOSS: module_{i}.py deleted when merge should have aborted " |
| 503 | "due to unreadable ours snapshot." |
| 504 | ) |
| 505 | |
| 506 | |
| 507 | # --------------------------------------------------------------------------- |
| 508 | # Category III — Empty-manifest guard tests |
| 509 | # --------------------------------------------------------------------------- |
| 510 | |
| 511 | |
| 512 | class TestEmptyManifestGuardIII: |
| 513 | """The merged result must never be applied to the working tree if it is |
| 514 | suspiciously empty given the inputs. |
| 515 | """ |
| 516 | |
| 517 | def test_III1_merge_result_snapshot_id_is_never_sha256_empty( |
| 518 | self, tmp_path: pathlib.Path |
| 519 | ) -> None: |
| 520 | """III1: a successful merge must never produce a commit with snapshot_id == SHA-256(""). |
| 521 | |
| 522 | e3b0c44… is the fingerprint of an empty snapshot; if it ever appears |
| 523 | in the commit graph, the merge engine produced an empty manifest and |
| 524 | deleted all tracked files. |
| 525 | """ |
| 526 | root, repo_id = _init_repo(tmp_path) |
| 527 | |
| 528 | f0 = _write_file(root, "a.py = 0\n") |
| 529 | base_c = _make_commit(root, repo_id, "main", "base", {"a.py": f0}) |
| 530 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 531 | |
| 532 | f1 = _write_file(root, "a.py = 1\n") |
| 533 | _make_commit(root, repo_id, "main", "ours", {"a.py": f1}) |
| 534 | |
| 535 | f2 = _write_file(root, "b.py = True\n") |
| 536 | _make_commit(root, repo_id, "feat", "theirs", {"a.py": f0, "b.py": f2}) |
| 537 | |
| 538 | code, out = _run(root, "merge", "feat") |
| 539 | assert code == 0, out |
| 540 | |
| 541 | from muse.core.store import read_commit |
| 542 | commit = read_commit(root, _ref(root, "main")) |
| 543 | assert commit is not None |
| 544 | assert commit.snapshot_id != _SHA256_EMPTY, ( |
| 545 | f"REGRESSION: merge commit has snapshot_id == SHA-256('') == {_SHA256_EMPTY[:16]}…\n" |
| 546 | "This means the merged manifest was empty and all tracked files were deleted.\n" |
| 547 | "This is the data-loss sentinel produced by compute_snapshot_id({})." |
| 548 | ) |
| 549 | |
| 550 | def test_III2_merged_manifest_has_at_least_as_many_files_as_base( |
| 551 | self, tmp_path: pathlib.Path |
| 552 | ) -> None: |
| 553 | """III2: clean merge → merged manifest >= base file count. |
| 554 | |
| 555 | When neither side deletes a file, the merged manifest must have AT LEAST |
| 556 | as many entries as the base. Fewer entries means files were silently dropped. |
| 557 | """ |
| 558 | root, repo_id = _init_repo(tmp_path) |
| 559 | |
| 560 | base_manifest = {f"file_{i}.py": _write_file(root, f"x_{i} = {i}\n") for i in range(20)} |
| 561 | base_c = _make_commit(root, repo_id, "main", "base", base_manifest) |
| 562 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 563 | |
| 564 | # ours: modify file_0.py only. |
| 565 | ours_manifest = {**base_manifest, "file_0.py": _write_file(root, "x_0 = 'ours'\n")} |
| 566 | _make_commit(root, repo_id, "main", "ours", ours_manifest) |
| 567 | |
| 568 | # theirs: modify file_1.py only. |
| 569 | theirs_manifest = {**base_manifest, "file_1.py": _write_file(root, "x_1 = 'theirs'\n")} |
| 570 | _make_commit(root, repo_id, "feat", "theirs", theirs_manifest) |
| 571 | |
| 572 | code, out = _run(root, "merge", "feat") |
| 573 | assert code == 0, out |
| 574 | |
| 575 | merged = _head_manifest(root, "main") |
| 576 | assert len(merged) >= len(base_manifest), ( |
| 577 | f"REGRESSION: merged manifest has {len(merged)} files but base had " |
| 578 | f"{len(base_manifest)}. Files were silently dropped." |
| 579 | ) |
| 580 | |
| 581 | def test_III3_merged_manifest_contains_all_base_files_when_no_deletions( |
| 582 | self, tmp_path: pathlib.Path |
| 583 | ) -> None: |
| 584 | """III3: no-deletion merge — every base file must appear in merged.""" |
| 585 | root, repo_id = _init_repo(tmp_path) |
| 586 | |
| 587 | base_manifest = {f"mod_{i}.py": _write_file(root, f"MOD_{i} = True\n") for i in range(15)} |
| 588 | base_c = _make_commit(root, repo_id, "main", "base", base_manifest) |
| 589 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 590 | |
| 591 | new_main = _write_file(root, "new_main.py = True\n") |
| 592 | _make_commit(root, repo_id, "main", "ours", {**base_manifest, "new_main.py": new_main}) |
| 593 | |
| 594 | new_feat = _write_file(root, "new_feat.py = True\n") |
| 595 | _make_commit(root, repo_id, "feat", "theirs", {**base_manifest, "new_feat.py": new_feat}) |
| 596 | |
| 597 | code, out = _run(root, "merge", "feat") |
| 598 | assert code == 0, out |
| 599 | |
| 600 | merged = _head_manifest(root, "main") |
| 601 | for path in base_manifest: |
| 602 | assert path in merged, ( |
| 603 | f"REGRESSION: base file '{path}' is missing from merged manifest. " |
| 604 | "Files are being silently dropped." |
| 605 | ) |
| 606 | |
| 607 | |
| 608 | # --------------------------------------------------------------------------- |
| 609 | # Category IV — Working-tree integrity (full CLI round-trips) |
| 610 | # --------------------------------------------------------------------------- |
| 611 | |
| 612 | |
| 613 | class TestWorkingTreeIntegrityIV: |
| 614 | """Full CLI merges must leave the working tree in a coherent state.""" |
| 615 | |
| 616 | def test_IV1_three_way_merge_working_tree_matches_snapshot( |
| 617 | self, tmp_path: pathlib.Path |
| 618 | ) -> None: |
| 619 | """IV1: after a clean merge, working tree files match the merged snapshot.""" |
| 620 | root, repo_id = _init_repo(tmp_path) |
| 621 | |
| 622 | content_a = b"A = 1\n" |
| 623 | content_b = b"B = 2\n" |
| 624 | a_id = _write_object(root, content_a) |
| 625 | b_id = _write_object(root, content_b) |
| 626 | |
| 627 | base_c = _make_commit(root, repo_id, "main", "base", {"a.py": a_id}) |
| 628 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 629 | |
| 630 | a2_content = b"A = 'ours'\n" |
| 631 | a2_id = _write_object(root, a2_content) |
| 632 | _make_commit(root, repo_id, "main", "ours", {"a.py": a2_id}) |
| 633 | |
| 634 | _make_commit(root, repo_id, "feat", "theirs", {"a.py": a_id, "b.py": b_id}) |
| 635 | |
| 636 | code, out = _run(root, "merge", "feat") |
| 637 | assert code == 0, out |
| 638 | |
| 639 | # After merge: working tree should have a.py (ours version) and b.py (theirs). |
| 640 | merged = _head_manifest(root, "main") |
| 641 | assert "b.py" in merged, "theirs-only b.py missing from merged snapshot" |
| 642 | assert merged.get("a.py") == a2_id, "ours change to a.py not preserved in merged snapshot" |
| 643 | |
| 644 | def test_IV2_fast_forward_file_count_preserved(self, tmp_path: pathlib.Path) -> None: |
| 645 | """IV2: fast-forward merge preserves ALL files from the target branch.""" |
| 646 | root, repo_id = _init_repo(tmp_path) |
| 647 | |
| 648 | # Write 25 files. |
| 649 | manifest: Manifest = {} |
| 650 | for i in range(25): |
| 651 | oid = _write_file(root, f"x_{i} = {i}\n") |
| 652 | manifest[f"file_{i:02d}.py"] = oid |
| 653 | |
| 654 | base_c = _make_commit(root, repo_id, "main", "base", {"start.py": _write_file(root, "x=0\n")}) |
| 655 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 656 | _make_commit(root, repo_id, "feat", "feat: 25 files", manifest) |
| 657 | |
| 658 | code, _out = _run(root, "merge", "feat") |
| 659 | assert code == 0 |
| 660 | |
| 661 | merged = _head_manifest(root, "main") |
| 662 | for path in manifest: |
| 663 | assert path in merged, f"DATA LOSS: {path} missing after fast-forward merge" |
| 664 | |
| 665 | def test_IV3_three_way_merge_both_sides_preserved(self, tmp_path: pathlib.Path) -> None: |
| 666 | """IV3: ours-only AND theirs-only files both present in merged result.""" |
| 667 | root, repo_id = _init_repo(tmp_path) |
| 668 | |
| 669 | base_id = _write_file(root, "base = True\n") |
| 670 | base_c = _make_commit(root, repo_id, "main", "base", {"base.py": base_id}) |
| 671 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 672 | |
| 673 | ours_id = _write_file(root, "ours = True\n") |
| 674 | _make_commit(root, repo_id, "main", "ours", {"base.py": base_id, "ours_only.py": ours_id}) |
| 675 | |
| 676 | theirs_id = _write_file(root, "theirs = True\n") |
| 677 | _make_commit(root, repo_id, "feat", "theirs", {"base.py": base_id, "theirs_only.py": theirs_id}) |
| 678 | |
| 679 | code, out = _run(root, "merge", "feat") |
| 680 | assert code == 0, out |
| 681 | |
| 682 | merged = _head_manifest(root, "main") |
| 683 | assert "ours_only.py" in merged, "ours-only file was dropped in merge" |
| 684 | assert "theirs_only.py" in merged, "theirs-only file was dropped in merge" |
| 685 | assert "base.py" in merged, "base file was dropped in merge" |
| 686 | |
| 687 | def test_IV4_merge_commit_snapshot_not_empty(self, tmp_path: pathlib.Path) -> None: |
| 688 | """IV4: merge commit snapshot_id must never be SHA-256 of empty bytes.""" |
| 689 | root, repo_id = _init_repo(tmp_path) |
| 690 | |
| 691 | f0 = _write_file(root, "a = 0\n") |
| 692 | f1 = _write_file(root, "a = 1\n") |
| 693 | f2 = _write_file(root, "b = True\n") |
| 694 | |
| 695 | base_c = _make_commit(root, repo_id, "main", "base", {"a.py": f0}) |
| 696 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 697 | _make_commit(root, repo_id, "main", "ours", {"a.py": f1}) |
| 698 | _make_commit(root, repo_id, "feat", "theirs", {"a.py": f0, "b.py": f2}) |
| 699 | |
| 700 | code, out = _run(root, "merge", "feat") |
| 701 | assert code == 0, out |
| 702 | |
| 703 | from muse.core.store import read_commit |
| 704 | mc = read_commit(root, _ref(root, "main")) |
| 705 | assert mc is not None |
| 706 | assert mc.snapshot_id != _SHA256_EMPTY, ( |
| 707 | "DATA LOSS: merge commit snapshot_id is SHA-256 of empty bytes. " |
| 708 | "The merged manifest was empty — all files were or would be deleted." |
| 709 | ) |
| 710 | |
| 711 | def test_IV5_merge_commit_has_two_parents(self, tmp_path: pathlib.Path) -> None: |
| 712 | """IV5: three-way merge commit must record both parent commit IDs.""" |
| 713 | root, repo_id = _init_repo(tmp_path) |
| 714 | |
| 715 | f0 = _write_file(root, "a = 0\n") |
| 716 | base_c = _make_commit(root, repo_id, "main", "base", {"a.py": f0}) |
| 717 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 718 | |
| 719 | f1 = _write_file(root, "a = 1\n") |
| 720 | _make_commit(root, repo_id, "main", "ours", {"a.py": f1}) |
| 721 | f2 = _write_file(root, "b = True\n") |
| 722 | _make_commit(root, repo_id, "feat", "theirs", {"a.py": f0, "b.py": f2}) |
| 723 | |
| 724 | _run(root, "merge", "feat") |
| 725 | |
| 726 | from muse.core.store import read_commit |
| 727 | mc = read_commit(root, _ref(root, "main")) |
| 728 | assert mc is not None |
| 729 | assert mc.parent2_commit_id is not None, ( |
| 730 | "Three-way merge commit missing second parent — history will appear linear." |
| 731 | ) |
| 732 | |
| 733 | def test_IV6_strategy_ours_does_not_delete_theirs_only_files( |
| 734 | self, tmp_path: pathlib.Path |
| 735 | ) -> None: |
| 736 | """IV6: --strategy=ours must not delete theirs-only files from merged manifest.""" |
| 737 | root, repo_id = _init_repo(tmp_path) |
| 738 | |
| 739 | f0 = _write_file(root, "shared = 0\n") |
| 740 | base_c = _make_commit(root, repo_id, "main", "base", {"shared.py": f0}) |
| 741 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 742 | |
| 743 | f_ours = _write_file(root, "shared = 'ours'\n") |
| 744 | theirs_only = _write_file(root, "new_feat = True\n") |
| 745 | _make_commit(root, repo_id, "main", "ours change", {"shared.py": f_ours}) |
| 746 | f_theirs = _write_file(root, "shared = 'theirs'\n") |
| 747 | _make_commit(root, repo_id, "feat", "theirs", |
| 748 | {"shared.py": f_theirs, "new_feat.py": theirs_only}) |
| 749 | |
| 750 | code, out = _run(root, "merge", "--strategy", "ours", "feat") |
| 751 | assert code == 0, out |
| 752 | |
| 753 | merged = _head_manifest(root, "main") |
| 754 | assert merged.get("shared.py") == f_ours, "strategy=ours must keep ours version of conflict" |
| 755 | assert "new_feat.py" in merged, ( |
| 756 | "REGRESSION: --strategy=ours deleted theirs-only new_feat.py. " |
| 757 | "Non-conflicting theirs additions must still appear in merged." |
| 758 | ) |
| 759 | |
| 760 | def test_IV7_strategy_theirs_does_not_delete_ours_only_files( |
| 761 | self, tmp_path: pathlib.Path |
| 762 | ) -> None: |
| 763 | """IV7: --strategy=theirs must not delete ours-only files from merged manifest.""" |
| 764 | root, repo_id = _init_repo(tmp_path) |
| 765 | |
| 766 | f0 = _write_file(root, "shared = 0\n") |
| 767 | base_c = _make_commit(root, repo_id, "main", "base", {"shared.py": f0}) |
| 768 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 769 | |
| 770 | ours_only = _write_file(root, "ours_new = True\n") |
| 771 | f_ours = _write_file(root, "shared = 'ours'\n") |
| 772 | _make_commit(root, repo_id, "main", "ours", |
| 773 | {"shared.py": f_ours, "ours_new.py": ours_only}) |
| 774 | |
| 775 | f_theirs = _write_file(root, "shared = 'theirs'\n") |
| 776 | _make_commit(root, repo_id, "feat", "theirs", {"shared.py": f_theirs}) |
| 777 | |
| 778 | code, out = _run(root, "merge", "--strategy", "theirs", "feat") |
| 779 | assert code == 0, out |
| 780 | |
| 781 | merged = _head_manifest(root, "main") |
| 782 | assert merged.get("shared.py") == f_theirs, "strategy=theirs must keep theirs version" |
| 783 | assert "ours_new.py" in merged, ( |
| 784 | "REGRESSION: --strategy=theirs deleted ours-only ours_new.py. " |
| 785 | "Non-conflicting ours additions must still appear in merged." |
| 786 | ) |
| 787 | |
| 788 | |
| 789 | # --------------------------------------------------------------------------- |
| 790 | # Category V — apply_manifest safety |
| 791 | # --------------------------------------------------------------------------- |
| 792 | |
| 793 | |
| 794 | class TestApplyManifestSafetyV: |
| 795 | """apply_manifest layer must be precise and not corrupt the working tree.""" |
| 796 | |
| 797 | def test_V1_apply_manifest_writes_target_files(self, tmp_path: pathlib.Path) -> None: |
| 798 | """V1: apply_manifest restores files from the object store correctly.""" |
| 799 | from muse.core.workdir import apply_manifest |
| 800 | |
| 801 | root, _ = _init_repo(tmp_path) |
| 802 | content = b"HELLO = True\n" |
| 803 | oid = _write_object(root, content) |
| 804 | apply_manifest(root, {"hello.py": oid}) |
| 805 | assert (root / "hello.py").read_bytes() == content |
| 806 | |
| 807 | def test_V2_apply_manifest_removes_files_not_in_target(self, tmp_path: pathlib.Path) -> None: |
| 808 | """V2: apply_manifest removes currently-tracked files absent from target.""" |
| 809 | from muse.core.workdir import apply_manifest |
| 810 | |
| 811 | root, _ = _init_repo(tmp_path) |
| 812 | content = b"OLD = True\n" |
| 813 | oid = _write_object(root, content) |
| 814 | (root / "old.py").write_bytes(content) |
| 815 | |
| 816 | # Apply a manifest that doesn't include old.py. |
| 817 | new_content = b"NEW = True\n" |
| 818 | new_oid = _write_object(root, new_content) |
| 819 | apply_manifest(root, {"new.py": new_oid}) |
| 820 | |
| 821 | assert not (root / "old.py").exists(), "apply_manifest should remove files not in target" |
| 822 | assert (root / "new.py").read_bytes() == new_content |
| 823 | |
| 824 | def test_V3_apply_manifest_does_not_delete_muse_dir(self, tmp_path: pathlib.Path) -> None: |
| 825 | """V3: apply_manifest must never delete .muse/ regardless of target.""" |
| 826 | from muse.core.workdir import apply_manifest |
| 827 | |
| 828 | root, _ = _init_repo(tmp_path) |
| 829 | assert (root / ".muse").exists() |
| 830 | |
| 831 | # Apply empty target — should not delete .muse. |
| 832 | try: |
| 833 | apply_manifest(root, {}) |
| 834 | except (ValueError, SystemExit): |
| 835 | pass # Guard fired correctly. |
| 836 | |
| 837 | assert (root / ".muse").exists(), ".muse/ was deleted by apply_manifest — critical failure" |
| 838 | |
| 839 | def test_V4_apply_manifest_does_not_follow_symlinks(self, tmp_path: pathlib.Path) -> None: |
| 840 | """V4: symlinked files outside the repo are not deleted by apply_manifest.""" |
| 841 | from muse.core.workdir import apply_manifest |
| 842 | |
| 843 | # Place the repo in a subdirectory so tmp_path itself can hold external files. |
| 844 | repo_dir = tmp_path / "myrepo" |
| 845 | repo_dir.mkdir() |
| 846 | root, _ = _init_repo(repo_dir) |
| 847 | |
| 848 | # Create a file that is genuinely outside the repo root. |
| 849 | external = tmp_path / "external_file.txt" |
| 850 | external.write_bytes(b"I am external") |
| 851 | |
| 852 | # Create a symlink inside the repo pointing to the external file. |
| 853 | link = root / "link_to_external.py" |
| 854 | link.symlink_to(external) |
| 855 | |
| 856 | # apply_manifest with any target must not delete the external file. |
| 857 | # walk_workdir uses os.lstat + S_ISREG, which skips symlinks → symlinks |
| 858 | # are excluded from current_files → the external target is never deleted. |
| 859 | try: |
| 860 | apply_manifest(root, {}) |
| 861 | except (ValueError, SystemExit): |
| 862 | pass # Guard fired — acceptable. |
| 863 | |
| 864 | assert external.exists(), ( |
| 865 | "apply_manifest followed a symlink outside the repo and deleted the target. " |
| 866 | "walk_workdir must exclude symlinks via S_ISREG check." |
| 867 | ) |
| 868 | |
| 869 | |
| 870 | # --------------------------------------------------------------------------- |
| 871 | # Category VI — The exact regression scenario |
| 872 | # --------------------------------------------------------------------------- |
| 873 | |
| 874 | |
| 875 | class TestFormatMigrationRegressionVI: |
| 876 | """Reproduce the exact scenario that caused the data-loss incident. |
| 877 | |
| 878 | When a branch that changes the on-disk store format (JSON→msgpack) is |
| 879 | merged into a branch that still uses the old format, the old reader |
| 880 | returns None for all snapshots, all manifests default to {}, and the |
| 881 | merge applies an empty working tree — deleting every file. |
| 882 | """ |
| 883 | |
| 884 | def test_VI1_regression_snapshot_id_never_equals_sha256_empty_after_merge( |
| 885 | self, tmp_path: pathlib.Path |
| 886 | ) -> None: |
| 887 | """VI1: the data-loss sentinel must never appear in the commit graph. |
| 888 | |
| 889 | Walk every commit in the graph after a merge and assert that no |
| 890 | snapshot_id equals e3b0c44… (SHA-256 of empty bytes). |
| 891 | """ |
| 892 | root, repo_id = _init_repo(tmp_path) |
| 893 | |
| 894 | # Build a real diverged graph with many files. |
| 895 | base_manifest = {f"src_{i}.py": _write_file(root, f"v = {i}\n") for i in range(10)} |
| 896 | base_c = _make_commit(root, repo_id, "main", "base", base_manifest) |
| 897 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 898 | |
| 899 | ours_manifest = {**base_manifest, "ours.py": _write_file(root, "OURS = True\n")} |
| 900 | _make_commit(root, repo_id, "main", "ours", ours_manifest) |
| 901 | |
| 902 | theirs_manifest = {**base_manifest, "theirs.py": _write_file(root, "THEIRS = True\n")} |
| 903 | _make_commit(root, repo_id, "feat", "theirs", theirs_manifest) |
| 904 | |
| 905 | code, out = _run(root, "merge", "feat") |
| 906 | assert code == 0, out |
| 907 | |
| 908 | # Walk the entire commit graph and check every snapshot_id. |
| 909 | from muse.core.store import read_commit |
| 910 | visited: set[str] = set() |
| 911 | queue = [_ref(root, "main")] |
| 912 | while queue: |
| 913 | cid = queue.pop() |
| 914 | if cid in visited: |
| 915 | continue |
| 916 | visited.add(cid) |
| 917 | commit = read_commit(root, cid) |
| 918 | if commit is None: |
| 919 | continue |
| 920 | assert commit.snapshot_id != _SHA256_EMPTY, ( |
| 921 | f"REGRESSION: commit {cid[:8]} has snapshot_id == SHA-256('') — " |
| 922 | "the data-loss sentinel. This commit has an empty manifest." |
| 923 | ) |
| 924 | if commit.parent_commit_id: |
| 925 | queue.append(commit.parent_commit_id) |
| 926 | if commit.parent2_commit_id: |
| 927 | queue.append(commit.parent2_commit_id) |
| 928 | |
| 929 | def test_VI2_merge_after_simulated_format_migration_aborts_not_deletes( |
| 930 | self, tmp_path: pathlib.Path |
| 931 | ) -> None: |
| 932 | """VI2: when the ours snapshot file is in the old JSON format, merge must abort. |
| 933 | |
| 934 | Simulates: branch A is on old code (JSON snapshots), branch B migrated to |
| 935 | msgpack. When A merges B using old-format readers, ours snapshot returns None. |
| 936 | The merge must abort rather than silently empty the working tree. |
| 937 | """ |
| 938 | root, repo_id = _init_repo(tmp_path) |
| 939 | |
| 940 | # Create 20 files in the working tree. |
| 941 | manifest: Manifest = {} |
| 942 | for i in range(20): |
| 943 | content = f"module_{i} = True\n".encode() |
| 944 | oid = _write_object(root, content) |
| 945 | (root / f"module_{i}.py").write_bytes(content) |
| 946 | manifest[f"module_{i}.py"] = oid |
| 947 | |
| 948 | base_c = _make_commit(root, repo_id, "main", "base", manifest) |
| 949 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 950 | |
| 951 | extra = _write_file(root, "extra = True\n") |
| 952 | _make_commit(root, repo_id, "feat", "feat adds extra", |
| 953 | {**manifest, "extra.py": extra}) |
| 954 | |
| 955 | bump = _write_file(root, "bump = True\n") |
| 956 | main_c = _make_commit(root, repo_id, "main", "main adds bump", |
| 957 | {**manifest, "bump.py": bump}) |
| 958 | |
| 959 | # Simulate format migration: overwrite ours snapshot with old JSON bytes. |
| 960 | from muse.core.store import read_commit |
| 961 | main_commit = read_commit(root, main_c) |
| 962 | assert main_commit is not None |
| 963 | snap_path = _snapshot_path(root, main_commit.snapshot_id) |
| 964 | # Write JSON (old format) — msgpack reader will fail on this. |
| 965 | old_json_bytes = json.dumps({ |
| 966 | "snapshot_id": main_commit.snapshot_id, |
| 967 | "manifest": {k: v for k, v in {**manifest, "bump.py": bump}.items()}, |
| 968 | }).encode() |
| 969 | snap_path.write_bytes(old_json_bytes) |
| 970 | |
| 971 | code, _out = _run_unchecked(root, "merge", "feat") |
| 972 | |
| 973 | # Either the merge aborts (code != 0) OR it succeeds with correct content. |
| 974 | # What is NEVER acceptable: merging with an empty manifest that deletes files. |
| 975 | if code == 0: |
| 976 | merged = _head_manifest(root, "main") |
| 977 | assert merged != {}, ( |
| 978 | "DATA LOSS: merge succeeded with an empty manifest. " |
| 979 | "All files were deleted because ours snapshot was unreadable (JSON vs msgpack)." |
| 980 | ) |
| 981 | # Must have non-trivially many files. |
| 982 | assert len(merged) >= len(manifest), ( |
| 983 | f"DATA LOSS: merged has only {len(merged)} files, expected ≥ {len(manifest)}." |
| 984 | ) |
| 985 | # If code != 0: correct behaviour (abort). |
| 986 | |
| 987 | # Critical: the working-tree files must still exist. |
| 988 | for i in range(20): |
| 989 | assert (root / f"module_{i}.py").exists(), ( |
| 990 | f"DATA LOSS: module_{i}.py was deleted when merge aborted due to unreadable snapshot." |
| 991 | ) |
| 992 | |
| 993 | def test_VI3_merge_commit_snapshot_id_matches_actual_files( |
| 994 | self, tmp_path: pathlib.Path |
| 995 | ) -> None: |
| 996 | """VI3: compute_snapshot_id of the merged manifest must equal the stored snapshot_id.""" |
| 997 | from muse.core.snapshot import compute_snapshot_id |
| 998 | from muse.core.store import read_commit, read_snapshot |
| 999 | |
| 1000 | root, repo_id = _init_repo(tmp_path) |
| 1001 | |
| 1002 | f0 = _write_file(root, "a = 0\n") |
| 1003 | f1 = _write_file(root, "a = 1\n") |
| 1004 | f2 = _write_file(root, "b = True\n") |
| 1005 | |
| 1006 | base_c = _make_commit(root, repo_id, "main", "base", {"a.py": f0}) |
| 1007 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 1008 | _make_commit(root, repo_id, "main", "ours", {"a.py": f1}) |
| 1009 | _make_commit(root, repo_id, "feat", "theirs", {"a.py": f0, "b.py": f2}) |
| 1010 | |
| 1011 | code, out = _run(root, "merge", "feat") |
| 1012 | assert code == 0, out |
| 1013 | |
| 1014 | commit = read_commit(root, _ref(root, "main")) |
| 1015 | assert commit is not None |
| 1016 | snap = read_snapshot(root, commit.snapshot_id) |
| 1017 | assert snap is not None |
| 1018 | |
| 1019 | recomputed = compute_snapshot_id(snap.manifest) |
| 1020 | assert recomputed == commit.snapshot_id, ( |
| 1021 | "snapshot_id in the commit record doesn't match " |
| 1022 | "compute_snapshot_id(snapshot.manifest). The snapshot is corrupt." |
| 1023 | ) |
| 1024 | |
| 1025 | |
| 1026 | # --------------------------------------------------------------------------- |
| 1027 | # Category VII — Stress tests |
| 1028 | # --------------------------------------------------------------------------- |
| 1029 | |
| 1030 | |
| 1031 | class TestStressVII: |
| 1032 | """Extreme stress tests: large file counts, repeated merges, complex topologies.""" |
| 1033 | |
| 1034 | def test_VII1_100_file_clean_merge_all_files_preserved(self, tmp_path: pathlib.Path) -> None: |
| 1035 | """VII1: merge with 100 theirs-only file additions — none may be dropped.""" |
| 1036 | root, repo_id = _init_repo(tmp_path) |
| 1037 | |
| 1038 | base_id = _write_file(root, "base = True\n") |
| 1039 | base_c = _make_commit(root, repo_id, "main", "base", {"base.py": base_id}) |
| 1040 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 1041 | |
| 1042 | # ours: minor bump to base.py. |
| 1043 | bumped = _write_file(root, "base = 2\n") |
| 1044 | _make_commit(root, repo_id, "main", "ours: bump", {"base.py": bumped}) |
| 1045 | |
| 1046 | # theirs: 100 new files. |
| 1047 | theirs_manifest: Manifest = {"base.py": base_id} |
| 1048 | for i in range(100): |
| 1049 | oid = _write_file(root, f"mod_{i:03d} = True\n") |
| 1050 | theirs_manifest[f"mod_{i:03d}.py"] = oid |
| 1051 | _make_commit(root, repo_id, "feat", "theirs: 100 mods", theirs_manifest) |
| 1052 | |
| 1053 | code, out = _run(root, "merge", "feat") |
| 1054 | assert code == 0, out |
| 1055 | |
| 1056 | merged = _head_manifest(root, "main") |
| 1057 | dropped = [f"mod_{i:03d}.py" for i in range(100) if f"mod_{i:03d}.py" not in merged] |
| 1058 | assert not dropped, ( |
| 1059 | f"DATA LOSS: {len(dropped)} of 100 theirs-only files dropped after merge: " |
| 1060 | f"{dropped[:5]}{'...' if len(dropped) > 5 else ''}" |
| 1061 | ) |
| 1062 | |
| 1063 | def test_VII2_repeated_merges_file_count_never_decreases( |
| 1064 | self, tmp_path: pathlib.Path |
| 1065 | ) -> None: |
| 1066 | """VII2: five sequential branch merges — total file count must be monotonically non-decreasing. |
| 1067 | |
| 1068 | Each wave: |
| 1069 | - Branches from the current main HEAD (inheriting all previously merged files). |
| 1070 | - Adds 5 unique files ON TOP of the current main state. |
| 1071 | - Main is bumped with 1 unique file (true 3-way merge). |
| 1072 | - After merge: main must have all prior files + 5 wave files + 1 bump. |
| 1073 | |
| 1074 | Expected final count: 1 (base) + 5 waves × 5 files + 5 bumps = 31. |
| 1075 | """ |
| 1076 | root, repo_id = _init_repo(tmp_path) |
| 1077 | |
| 1078 | base_id = _write_file(root, "base = True\n") |
| 1079 | _make_commit(root, repo_id, "main", "base", {"base.py": base_id}) |
| 1080 | prev_count = 1 |
| 1081 | |
| 1082 | for wave in range(5): |
| 1083 | branch = f"wave_{wave}" |
| 1084 | # Branch from the current main HEAD — inherits all previously merged files. |
| 1085 | (root / ".muse" / "refs" / "heads" / branch).write_text(_ref(root, "main")) |
| 1086 | |
| 1087 | # Wave branch: current main state + 5 new unique files. |
| 1088 | wave_manifest = dict(_head_manifest(root, "main")) |
| 1089 | for j in range(5): |
| 1090 | oid = _write_file(root, f"wave_{wave}_file_{j} = True\n") |
| 1091 | wave_manifest[f"w{wave}_{j}.py"] = oid |
| 1092 | _make_commit(root, repo_id, branch, f"wave {wave} adds 5 files", wave_manifest) |
| 1093 | |
| 1094 | # Advance main with 1 unique file so this is a true 3-way merge. |
| 1095 | bump_manifest = dict(_head_manifest(root, "main")) |
| 1096 | bump_id = _write_file(root, f"main_bump_{wave} = True\n") |
| 1097 | bump_manifest[f"main_bump_{wave}.py"] = bump_id |
| 1098 | _make_commit(root, repo_id, "main", f"main bump {wave}", bump_manifest) |
| 1099 | |
| 1100 | code, out = _run(root, "merge", branch) |
| 1101 | assert code == 0, f"Wave {wave} merge failed: {out}" |
| 1102 | |
| 1103 | current_count = len(_head_manifest(root, "main")) |
| 1104 | assert current_count >= prev_count, ( |
| 1105 | f"DATA LOSS after wave {wave}: file count decreased " |
| 1106 | f"from {prev_count} to {current_count}." |
| 1107 | ) |
| 1108 | prev_count = current_count |
| 1109 | |
| 1110 | # 1 base + 5 waves × 5 files + 5 bumps = 31. |
| 1111 | assert prev_count >= 31, ( |
| 1112 | f"Expected at least 31 files after 5 waves, got {prev_count}." |
| 1113 | ) |
| 1114 | |
| 1115 | def test_VII3_diamond_topology_no_files_lost(self, tmp_path: pathlib.Path) -> None: |
| 1116 | """VII3: diamond merge topology — LCA is correctly found, no data lost. |
| 1117 | |
| 1118 | Topology: |
| 1119 | C0 (base: 10 files) |
| 1120 | / \\ |
| 1121 | C1 C2 |
| 1122 | (ours adds 5) (theirs adds 5 different) |
| 1123 | \\ / |
| 1124 | merge → must have all 20 files |
| 1125 | """ |
| 1126 | root, repo_id = _init_repo(tmp_path) |
| 1127 | |
| 1128 | base_manifest = {f"base_{i}.py": _write_file(root, f"base_{i} = True\n") for i in range(10)} |
| 1129 | base_c = _make_commit(root, repo_id, "main", "C0", base_manifest) |
| 1130 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 1131 | |
| 1132 | # C1: ours adds 5 files. |
| 1133 | c1_manifest = {**base_manifest} |
| 1134 | for i in range(5): |
| 1135 | c1_manifest[f"ours_{i}.py"] = _write_file(root, f"ours_{i} = True\n") |
| 1136 | _make_commit(root, repo_id, "main", "C1: ours adds 5", c1_manifest) |
| 1137 | |
| 1138 | # C2: theirs adds 5 different files. |
| 1139 | c2_manifest = {**base_manifest} |
| 1140 | for i in range(5): |
| 1141 | c2_manifest[f"theirs_{i}.py"] = _write_file(root, f"theirs_{i} = True\n") |
| 1142 | _make_commit(root, repo_id, "feat", "C2: theirs adds 5", c2_manifest) |
| 1143 | |
| 1144 | code, out = _run(root, "merge", "feat") |
| 1145 | assert code == 0, out |
| 1146 | |
| 1147 | merged = _head_manifest(root, "main") |
| 1148 | assert len(merged) == 20, ( |
| 1149 | f"DATA LOSS: expected 20 files after diamond merge, got {len(merged)}. " |
| 1150 | f"Missing: {sorted(set(list(c1_manifest) + list(c2_manifest)) - set(merged))}" |
| 1151 | ) |
| 1152 | |
| 1153 | def test_VII4_merge_with_deep_history_correct_lca(self, tmp_path: pathlib.Path) -> None: |
| 1154 | """VII4: 50-commit deep history — LCA found correctly, no files lost.""" |
| 1155 | root, repo_id = _init_repo(tmp_path) |
| 1156 | |
| 1157 | # Build 50 commits on main. |
| 1158 | f0 = _write_file(root, "anchor = 0\n") |
| 1159 | current_manifest: Manifest = {"anchor.py": f0} |
| 1160 | base_c = _make_commit(root, repo_id, "main", "C0", current_manifest) |
| 1161 | |
| 1162 | for depth in range(49): |
| 1163 | fi = _write_file(root, f"depth_{depth} = True\n") |
| 1164 | current_manifest = {**current_manifest, f"depth_{depth}.py": fi} |
| 1165 | _make_commit(root, repo_id, "main", f"C{depth + 1}", current_manifest) |
| 1166 | |
| 1167 | # Branch at the VERY END. |
| 1168 | tip_c = _ref(root, "main") |
| 1169 | (root / ".muse" / "refs" / "heads" / "feat").write_text(tip_c) |
| 1170 | |
| 1171 | # Advance main by 1. |
| 1172 | main_extra = _write_file(root, "main_extra = True\n") |
| 1173 | main_manifest = {**current_manifest, "main_extra.py": main_extra} |
| 1174 | _make_commit(root, repo_id, "main", "main advance", main_manifest) |
| 1175 | |
| 1176 | # Advance feat by 1. |
| 1177 | feat_extra = _write_file(root, "feat_extra = True\n") |
| 1178 | feat_manifest = {**current_manifest, "feat_extra.py": feat_extra} |
| 1179 | _make_commit(root, repo_id, "feat", "feat advance", feat_manifest) |
| 1180 | |
| 1181 | code, out = _run(root, "merge", "feat") |
| 1182 | assert code == 0, out |
| 1183 | |
| 1184 | merged = _head_manifest(root, "main") |
| 1185 | assert "main_extra.py" in merged, "ours-only main_extra.py lost in deep-history merge" |
| 1186 | assert "feat_extra.py" in merged, "theirs-only feat_extra.py lost in deep-history merge" |
| 1187 | # All 49 depth files must still be present. |
| 1188 | for depth in range(49): |
| 1189 | assert f"depth_{depth}.py" in merged, f"depth_{depth}.py lost in deep-history merge" |
| 1190 | |
| 1191 | def test_VII5_stress_strategy_ours_100_theirs_files_all_preserved( |
| 1192 | self, tmp_path: pathlib.Path |
| 1193 | ) -> None: |
| 1194 | """VII5: --strategy=ours with 100 theirs-only additions — all must appear in merged. |
| 1195 | |
| 1196 | The old strategy=ours bug took the entire ours manifest verbatim, |
| 1197 | discarding all theirs-only changes. This test ensures 100 theirs-only |
| 1198 | files survive even when strategy=ours is used to resolve conflicts. |
| 1199 | """ |
| 1200 | root, repo_id = _init_repo(tmp_path) |
| 1201 | |
| 1202 | shared_content = _write_file(root, "shared = 'base'\n") |
| 1203 | base_c = _make_commit(root, repo_id, "main", "base", {"shared.py": shared_content}) |
| 1204 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 1205 | |
| 1206 | ours_shared = _write_file(root, "shared = 'ours'\n") |
| 1207 | _make_commit(root, repo_id, "main", "ours: modify shared", {"shared.py": ours_shared}) |
| 1208 | |
| 1209 | # theirs: conflict on shared.py + 100 theirs-only additions. |
| 1210 | theirs_shared = _write_file(root, "shared = 'theirs'\n") |
| 1211 | theirs_manifest: Manifest = {"shared.py": theirs_shared} |
| 1212 | for i in range(100): |
| 1213 | oid = _write_file(root, f"extra_{i} = True\n") |
| 1214 | theirs_manifest[f"extra_{i:03d}.py"] = oid |
| 1215 | _make_commit(root, repo_id, "feat", "theirs: conflict + 100 extras", theirs_manifest) |
| 1216 | |
| 1217 | code, out = _run(root, "merge", "--strategy", "ours", "feat") |
| 1218 | assert code == 0, out |
| 1219 | |
| 1220 | merged = _head_manifest(root, "main") |
| 1221 | dropped = [f"extra_{i:03d}.py" for i in range(100) if f"extra_{i:03d}.py" not in merged] |
| 1222 | assert not dropped, ( |
| 1223 | f"REGRESSION: --strategy=ours dropped {len(dropped)} theirs-only files: " |
| 1224 | f"{dropped[:5]}{'...' if len(dropped) > 5 else ''}" |
| 1225 | ) |
| 1226 | assert merged.get("shared.py") == ours_shared, "--strategy=ours must keep ours version of conflict" |
| 1227 | |
| 1228 | def test_VII6_interleaved_add_delete_all_correct(self, tmp_path: pathlib.Path) -> None: |
| 1229 | """VII6: interleaved adds and deletes on both sides — final manifest exactly correct.""" |
| 1230 | root, repo_id = _init_repo(tmp_path) |
| 1231 | |
| 1232 | # Base: files 0-19. |
| 1233 | base_manifest = {f"f{i:02d}.py": _write_file(root, f"f{i} = {i}\n") for i in range(20)} |
| 1234 | base_c = _make_commit(root, repo_id, "main", "base", base_manifest) |
| 1235 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 1236 | |
| 1237 | # ours: delete even files (0,2,4…18), keep odd, add ours_new. |
| 1238 | ours_manifest = {k: v for k, v in base_manifest.items() if int(k[1:3]) % 2 == 1} |
| 1239 | ours_manifest["ours_new.py"] = _write_file(root, "OURS_NEW = True\n") |
| 1240 | _make_commit(root, repo_id, "main", "ours: delete evens, add ours_new", ours_manifest) |
| 1241 | |
| 1242 | # theirs: delete files 0-9, keep 10-19, add theirs_new. |
| 1243 | theirs_manifest = {k: v for k, v in base_manifest.items() if int(k[1:3]) >= 10} |
| 1244 | theirs_manifest["theirs_new.py"] = _write_file(root, "THEIRS_NEW = True\n") |
| 1245 | _make_commit(root, repo_id, "feat", "theirs: delete f00-f09, add theirs_new", theirs_manifest) |
| 1246 | |
| 1247 | code, out = _run(root, "merge", "feat") |
| 1248 | assert code == 0, out |
| 1249 | |
| 1250 | merged = _head_manifest(root, "main") |
| 1251 | |
| 1252 | # Files deleted by ours (evens 0-18): ours deleted, theirs may have kept some. |
| 1253 | # The three-way merge rule: if ours deleted and theirs didn't change → keep deleted. |
| 1254 | # Files deleted by theirs (0-9): theirs deleted, ours may have kept some. |
| 1255 | |
| 1256 | # ours_new and theirs_new must both be present. |
| 1257 | assert "ours_new.py" in merged, "ours_new.py was lost in interleaved merge" |
| 1258 | assert "theirs_new.py" in merged, "theirs_new.py was lost in interleaved merge" |
| 1259 | |
| 1260 | # No extra phantom files. |
| 1261 | for path in merged: |
| 1262 | assert path in ours_manifest or path in theirs_manifest or path in base_manifest or \ |
| 1263 | path in ("ours_new.py", "theirs_new.py"), ( |
| 1264 | f"Phantom file {path!r} in merged manifest — not from any input" |
| 1265 | ) |
| 1266 | |
| 1267 | def test_VII7_merge_output_is_deterministic(self, tmp_path: pathlib.Path) -> None: |
| 1268 | """VII7: merging the same two branches twice produces the same commit_id. |
| 1269 | |
| 1270 | Because commit_id is computed from (parent_ids, snapshot_id, message, timestamp), |
| 1271 | two runs with the same timestamp must produce the same commit_id. This |
| 1272 | tests that the merge is truly deterministic. |
| 1273 | """ |
| 1274 | from muse.core.merge_engine import apply_merge, detect_conflicts, diff_snapshots |
| 1275 | from muse.core.snapshot import compute_snapshot_id |
| 1276 | |
| 1277 | # Build a deterministic merge scenario at the pure-function level. |
| 1278 | base = {"a.py": _h("a-base"), "b.py": _h("b-base")} |
| 1279 | ours = {"a.py": _h("a-ours"), "b.py": _h("b-base")} |
| 1280 | theirs = {"a.py": _h("a-base"), "b.py": _h("b-theirs"), "c.py": _h("c-new")} |
| 1281 | |
| 1282 | ours_changed = diff_snapshots(base, ours) |
| 1283 | theirs_changed = diff_snapshots(base, theirs) |
| 1284 | conflicts = detect_conflicts(ours_changed, theirs_changed, ours, theirs) |
| 1285 | merged1 = apply_merge(base, ours, theirs, ours_changed, theirs_changed, conflicts) |
| 1286 | merged2 = apply_merge(base, ours, theirs, ours_changed, theirs_changed, conflicts) |
| 1287 | |
| 1288 | assert merged1 == merged2, "apply_merge is not deterministic" |
| 1289 | assert compute_snapshot_id(merged1) == compute_snapshot_id(merged2), ( |
| 1290 | "compute_snapshot_id is not deterministic" |
| 1291 | ) |
| 1292 | |
| 1293 | def test_VII8_dry_run_never_modifies_any_commit(self, tmp_path: pathlib.Path) -> None: |
| 1294 | """VII8: --dry-run must not write any commit or advance any branch ref.""" |
| 1295 | root, repo_id = _init_repo(tmp_path) |
| 1296 | |
| 1297 | f0 = _write_file(root, "a = 0\n") |
| 1298 | base_c = _make_commit(root, repo_id, "main", "base", {"a.py": f0}) |
| 1299 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 1300 | |
| 1301 | f1 = _write_file(root, "a = 1\n") |
| 1302 | main_c = _make_commit(root, repo_id, "main", "ours", {"a.py": f1}) |
| 1303 | |
| 1304 | f2 = _write_file(root, "b = True\n") |
| 1305 | _make_commit(root, repo_id, "feat", "theirs", {"a.py": f0, "b.py": f2}) |
| 1306 | |
| 1307 | snapshot_count_before = len(list((root / ".muse" / "snapshots").glob("*.msgpack"))) |
| 1308 | commit_count_before = len(list((root / ".muse" / "commits").glob("*.msgpack"))) |
| 1309 | |
| 1310 | code, _out = _run(root, "merge", "--dry-run", "feat") |
| 1311 | assert code == 0 |
| 1312 | |
| 1313 | snapshot_count_after = len(list((root / ".muse" / "snapshots").glob("*.msgpack"))) |
| 1314 | commit_count_after = len(list((root / ".muse" / "commits").glob("*.msgpack"))) |
| 1315 | |
| 1316 | assert _ref(root, "main") == main_c, "--dry-run must not advance main HEAD" |
| 1317 | assert snapshot_count_after == snapshot_count_before, "--dry-run must not write snapshots" |
| 1318 | assert commit_count_after == commit_count_before, "--dry-run must not write commits" |
| 1319 | |
| 1320 | def test_VII9_no_ff_with_100_files_all_preserved(self, tmp_path: pathlib.Path) -> None: |
| 1321 | """VII9: --no-ff with 100-file fast-forward-eligible merge — all files preserved.""" |
| 1322 | root, repo_id = _init_repo(tmp_path) |
| 1323 | |
| 1324 | base_id = _write_file(root, "anchor = True\n") |
| 1325 | base_c = _make_commit(root, repo_id, "main", "base", {"anchor.py": base_id}) |
| 1326 | (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c) |
| 1327 | |
| 1328 | large_manifest: Manifest = {"anchor.py": base_id} |
| 1329 | for i in range(100): |
| 1330 | oid = _write_file(root, f"big_{i:03d} = True\n") |
| 1331 | large_manifest[f"big_{i:03d}.py"] = oid |
| 1332 | _make_commit(root, repo_id, "feat", "feat: 100 files", large_manifest) |
| 1333 | |
| 1334 | # --no-ff forces a three-way merge commit even though this is fast-forwardable. |
| 1335 | code, out = _run(root, "merge", "--no-ff", "feat") |
| 1336 | assert code == 0, out |
| 1337 | |
| 1338 | merged = _head_manifest(root, "main") |
| 1339 | for i in range(100): |
| 1340 | assert f"big_{i:03d}.py" in merged, f"big_{i:03d}.py missing after --no-ff merge" |
| 1341 | |
| 1342 | # Must have created a real merge commit (two parents). |
| 1343 | from muse.core.store import read_commit |
| 1344 | mc = read_commit(root, _ref(root, "main")) |
| 1345 | assert mc is not None |
| 1346 | assert mc.parent2_commit_id is not None, "--no-ff must produce a merge commit with 2 parents" |
File History
5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e
Merge branch 'dev' into main
Human
29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce
Merge branch 'dev' into main
Human
48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa
feat: Muse — version control for the agent era
Human
100 days ago