gabriel / muse public
test_merge_data_integrity.py python
1,346 lines 58.6 KB
Raw
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