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