gabriel / muse public
test_workdir_integrity.py python
1,204 lines 45.7 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 127 days ago
1 """Zero-data-loss workdir integrity tests.
2
3 What these tests cover
4 ----------------------
5 This suite was written after a real incident where the working tree diverged
6 from the committed snapshot. The root cause chain:
7
8 1. ``restore_object`` used ``shutil.copy2(src, dest)`` directly — not
9 atomic. A crash mid-copy could leave a corrupt destination file.
10 2. ``apply_manifest`` ignored the ``False`` return from ``restore_object``
11 when an object was absent from the store. The file was silently left at
12 its old content; no error surfaced.
13 3. ``_checkout_snapshot`` (incremental delta path) printed a warning when
14 an object was missing but continued — same silent data loss.
15 4. No post-operation integrity verification existed to catch any of the
16 above after the fact.
17
18 Fixes applied:
19 * ``restore_object`` — atomic write: temp file → ``os.replace``.
20 * ``apply_manifest`` — raises ``RuntimeError`` listing every missing object.
21 * ``_checkout_snapshot`` — raises ``SystemExit(INTERNAL_ERROR)`` on missing
22 object; never continues with a partial workdir.
23 * ``verify_workdir_integrity`` — new utility: full hash-based post-op audit.
24
25 Test categories
26 ---------------
27 I restore_object atomicity (temp+replace pattern).
28 II apply_manifest — missing object raises, not silently skips.
29 III verify_workdir_integrity — utility correctness.
30 IV checkout → workdir always matches target snapshot.
31 V fast-forward merge → workdir always matches target snapshot.
32 VI checkout aborts hard when an object is missing from the store.
33 VII Editor-cache simulation — status detects stale-cache workdir drift.
34 VIII Stress tests — 500-file repos, deep chains, diamond DAGs.
35 """
36
37 from __future__ import annotations
38
39 import hashlib
40 import json
41 import os
42 import pathlib
43 import shutil
44 import stat
45 import tempfile
46
47 import pytest
48 from tests.cli_test_helper import CliRunner
49
50 from muse.core.object_store import object_path, restore_object, write_object
51 from muse.core.snapshot import walk_workdir
52 from muse.core.workdir import apply_manifest, verify_workdir_integrity
53 from muse.core.types import Manifest, blob_id, content_hash, fake_id, hash_file
54 from muse.core.paths import commits_dir, muse_dir, ref_path, snapshots_dir
55
56 type _EnvMap = dict[str, str]
57
58 runner = CliRunner()
59 cli = None # CliRunner ignores this positional
60
61
62 # ---------------------------------------------------------------------------
63 # Shared helpers
64 # ---------------------------------------------------------------------------
65
66
67
68
69 def _env(root: pathlib.Path) -> _EnvMap:
70 return {"MUSE_REPO_ROOT": str(root)}
71
72
73 def _run(root: pathlib.Path, *args: str) -> tuple[int, str]:
74 final = list(args)
75 if final and final[0] == "merge" and "--force" not in final:
76 final.insert(1, "--force")
77 result = runner.invoke(cli, final, env=_env(root), catch_exceptions=False)
78 return result.exit_code, result.output
79
80
81 def _run_unchecked(root: pathlib.Path, *args: str) -> tuple[int, str]:
82 final = list(args)
83 if final and final[0] == "merge" and "--force" not in final:
84 final.insert(1, "--force")
85 result = runner.invoke(cli, final, env=_env(root))
86 return result.exit_code, result.output
87
88
89 def _store_object(root: pathlib.Path, content: bytes) -> str:
90 """Write *content* to the object store, return its object ID."""
91 oid = blob_id(content)
92 write_object(root, oid, content)
93 return oid
94
95
96 def _object_path(root: pathlib.Path, oid: str) -> pathlib.Path:
97 return object_path(root, oid)
98
99
100 def _init_repo(tmp_path: pathlib.Path, domain: str = "code") -> tuple[pathlib.Path, str]:
101 dot_muse = muse_dir(tmp_path)
102 dot_muse.mkdir()
103 repo_id = fake_id("repo")
104 (dot_muse / "repo.json").write_text(json.dumps({
105 "repo_id": repo_id,
106 "domain": domain,
107 "version": "1.0.0",
108 }))
109 (dot_muse / "refs" / "heads").mkdir(parents=True)
110 (dot_muse / "objects").mkdir()
111 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
112 return tmp_path, repo_id
113
114
115 def _make_commit(
116 root: pathlib.Path,
117 repo_id: str,
118 branch: str,
119 message: str,
120 manifest: Manifest,
121 ) -> str:
122 """Write objects, snapshot, and commit; update branch ref. Returns commit id."""
123 import msgpack
124
125 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
126 from muse.core.store import read_commit
127 from muse.core.types import now_utc_iso
128
129 snap_id = compute_snapshot_id(manifest)
130 snap_path = snapshots_dir(root) / f"{snap_id}.msgpack"
131 snap_path.parent.mkdir(exist_ok=True)
132 snap_path.write_bytes(msgpack.packb({
133 "snapshot_id": snap_id,
134 "manifest": manifest,
135 }, use_bin_type=True))
136
137 ref_path = ref_path(root, branch)
138 parent_id: str | None = ref_path.read_text().strip() if ref_path.exists() else None
139
140 committed_at = now_utc_iso()
141 commit_id = compute_commit_id(
142 parent_ids=[parent_id] if parent_id else [],
143 snapshot_id=snap_id,
144 message=message,
145 committed_at_iso=committed_at,
146 )
147
148 commits_dir = commits_dir(root)
149 commits_dir.mkdir(exist_ok=True)
150 commit_path = commits_dir / f"{commit_id}.msgpack"
151 commit_path.write_bytes(msgpack.packb({
152 "commit_id": commit_id,
153 "repo_id": repo_id,
154 "branch": branch,
155 "snapshot_id": snap_id,
156 "message": message,
157 "committed_at": committed_at,
158 "parent_commit_id": parent_id,
159 "parent2_commit_id": None,
160 "author": "test",
161 "metadata": {},
162 "structured_delta": None,
163 "sem_ver_bump": "none",
164 "breaking_changes": [],
165 "agent_id": "",
166 "model_id": "",
167 "toolchain_id": "",
168 "prompt_hash": "",
169 "signature": "",
170 "signer_key_id": "",
171 }, use_bin_type=True))
172 ref_path.parent.mkdir(parents=True, exist_ok=True)
173 ref_path.write_text(commit_id)
174 return commit_id
175
176
177 def _head_manifest(root: pathlib.Path, branch: str) -> Manifest:
178 from muse.core.store import read_commit, read_snapshot
179 ref = (ref_path(root, branch)).read_text().strip()
180 cr = read_commit(root, ref)
181 assert cr is not None
182 sr = read_snapshot(root, cr.snapshot_id)
183 assert sr is not None
184 return dict(sr.manifest)
185
186
187 def _write_disk(root: pathlib.Path, rel_path: str, content: bytes) -> str:
188 """Write content to disk AND store it; return object id."""
189 fp = root / rel_path
190 fp.parent.mkdir(parents=True, exist_ok=True)
191 fp.write_bytes(content)
192 return _store_object(root, content)
193
194
195 # ===========================================================================
196 # I restore_object atomicity
197 # ===========================================================================
198
199
200 class TestRestoreObjectAtomicityI:
201 """restore_object must use atomic writes so a crash mid-copy never
202 leaves a partial file at the destination."""
203
204 def test_I1_successful_restore_produces_correct_content(
205 self, tmp_path: pathlib.Path
206 ) -> None:
207 """I1: happy-path restore writes exact bytes to dest."""
208 root, _ = _init_repo(tmp_path)
209 content = b"hello world\n" * 100
210 oid = _store_object(root, content)
211 dest = tmp_path / "out.bin"
212 assert restore_object(root, oid, dest)
213 assert dest.read_bytes() == content
214
215 def test_I2_restore_overwrites_existing_file(self, tmp_path: pathlib.Path) -> None:
216 """I2: restore replaces whatever was at dest (no skip-if-exists)."""
217 root, _ = _init_repo(tmp_path)
218 old = b"old content\n"
219 new = b"new content\n"
220 dest = tmp_path / "f.txt"
221 dest.write_bytes(old)
222
223 oid = _store_object(root, new)
224 assert restore_object(root, oid, dest)
225 assert dest.read_bytes() == new
226
227 def test_I3_restore_missing_object_returns_false_does_not_touch_dest(
228 self, tmp_path: pathlib.Path
229 ) -> None:
230 """I3: missing object → False, pre-existing dest left intact."""
231 root, _ = _init_repo(tmp_path)
232 sentinel = b"sentinel\n"
233 dest = tmp_path / "existing.txt"
234 dest.write_bytes(sentinel)
235
236 fake_oid = blob_id(b"nonexistent")
237 assert not restore_object(root, fake_oid, dest)
238 assert dest.read_bytes() == sentinel
239
240 def test_I4_restore_creates_parent_directories(
241 self, tmp_path: pathlib.Path
242 ) -> None:
243 """I4: dest parent dirs are created automatically."""
244 root, _ = _init_repo(tmp_path)
245 content = b"deep\n"
246 oid = _store_object(root, content)
247 dest = tmp_path / "a" / "b" / "c" / "deep.txt"
248 assert not dest.parent.exists()
249 assert restore_object(root, oid, dest)
250 assert dest.read_bytes() == content
251
252 def test_I5_atomic_write_leaves_no_tmp_file_on_success(
253 self, tmp_path: pathlib.Path
254 ) -> None:
255 """I5: after a successful restore no .restore-tmp-* file lingers."""
256 root, _ = _init_repo(tmp_path)
257 content = b"data\n"
258 oid = _store_object(root, content)
259 dest = tmp_path / "target.txt"
260 restore_object(root, oid, dest)
261 tmps = list(tmp_path.glob(".restore-tmp-*"))
262 assert tmps == [], f"Stale tmp files found: {tmps}"
263
264 def test_I6_restore_hash_after_restore_matches_object_id(
265 self, tmp_path: pathlib.Path
266 ) -> None:
267 """I6: the restored file's SHA-256 matches the object_id exactly."""
268 root, _ = _init_repo(tmp_path)
269 content = b"integrity\n" * 1000
270 oid = _store_object(root, content)
271 dest = tmp_path / "verified.bin"
272 restore_object(root, oid, dest)
273 actual = hash_file(dest)
274 assert actual == oid, f"Hash mismatch after restore: {actual[:8]} ≠ {oid[:8]}"
275
276 def test_I7_restore_large_file_correct_content(
277 self, tmp_path: pathlib.Path
278 ) -> None:
279 """I7: 10 MiB blob survives a round-trip through the object store."""
280 root, _ = _init_repo(tmp_path)
281 content = os.urandom(10 * 1024 * 1024)
282 oid = _store_object(root, content)
283 dest = tmp_path / "large.bin"
284 assert restore_object(root, oid, dest)
285 assert dest.read_bytes() == content
286
287
288 # ===========================================================================
289 # II apply_manifest — missing object must raise, never silently skip
290 # ===========================================================================
291
292
293 class TestApplyManifestMissingObjectII:
294 """apply_manifest must fail loudly when any object is absent."""
295
296 def test_II1_missing_single_object_raises_runtime_error(
297 self, tmp_path: pathlib.Path
298 ) -> None:
299 """II1: one missing object → RuntimeError, not silent skip."""
300 root, _ = _init_repo(tmp_path)
301 fake_oid = blob_id(b"ghost")
302 with pytest.raises(RuntimeError, match="missing from the local store"):
303 apply_manifest(root, {}, {"ghost.txt": fake_oid})
304
305 def test_II2_error_message_names_the_missing_path(
306 self, tmp_path: pathlib.Path
307 ) -> None:
308 """II2: the error message includes the missing path name."""
309 root, _ = _init_repo(tmp_path)
310 fake_oid = blob_id(b"abc")
311 with pytest.raises(RuntimeError) as exc_info:
312 apply_manifest(root, {}, {"crucial/file.py": fake_oid})
313 assert "crucial/file.py" in str(exc_info.value)
314
315 def test_II3_partial_manifest_some_missing_raises(
316 self, tmp_path: pathlib.Path
317 ) -> None:
318 """II3: when one of N files is missing the whole call raises."""
319 root, _ = _init_repo(tmp_path)
320 good_oid = _write_disk(root, "exists.txt", b"ok\n")
321 bad_oid = blob_id(b"not stored")
322 with pytest.raises(RuntimeError):
323 apply_manifest(root, {}, {"exists.txt": good_oid, "missing.txt": bad_oid})
324
325 def test_II4_all_objects_present_succeeds(
326 self, tmp_path: pathlib.Path
327 ) -> None:
328 """II4: when every object is in the store apply_manifest succeeds."""
329 root, _ = _init_repo(tmp_path)
330 oid_a = _write_disk(root, "a.txt", b"aaa\n")
331 oid_b = _write_disk(root, "b.txt", b"bbb\n")
332 (root / "a.txt").unlink()
333 (root / "b.txt").unlink()
334 apply_manifest(root, {}, {"a.txt": oid_a, "b.txt": oid_b})
335 assert (root / "a.txt").read_bytes() == b"aaa\n"
336 assert (root / "b.txt").read_bytes() == b"bbb\n"
337
338 def test_II5_multiple_missing_reported_in_error(
339 self, tmp_path: pathlib.Path
340 ) -> None:
341 """II5: error message covers multiple missing files."""
342 root, _ = _init_repo(tmp_path)
343 manifest = {f"f{i}.py": blob_id(f"fake{i}".encode()) for i in range(10)}
344 with pytest.raises(RuntimeError) as exc_info:
345 apply_manifest(root, {}, manifest)
346 msg = str(exc_info.value)
347 assert "10 object(s)" in msg
348
349 def test_II6_apply_manifest_removes_files_not_in_target(
350 self, tmp_path: pathlib.Path
351 ) -> None:
352 """II6: tracked files absent from target manifest are deleted."""
353 root, _ = _init_repo(tmp_path)
354 keep_oid = _write_disk(root, "keep.txt", b"keep\n")
355 del_oid = _store_object(root, b"delete\n")
356 (root / "delete_me.txt").write_bytes(b"delete\n")
357 # delete_me.txt is in prev_manifest (was tracked) but not in target — must be removed
358 apply_manifest(
359 root,
360 {"keep.txt": keep_oid, "delete_me.txt": del_oid},
361 {"keep.txt": keep_oid},
362 )
363 assert not (root / "delete_me.txt").exists()
364 assert (root / "keep.txt").exists()
365
366 def test_II7_empty_manifest_non_empty_prev_raises_value_error(
367 self, tmp_path: pathlib.Path
368 ) -> None:
369 """II7: data-loss guard — empty target with non-empty prev_manifest raises ValueError."""
370 root, _ = _init_repo(tmp_path)
371 oid = _store_object(root, b"data\n")
372 with pytest.raises(ValueError, match="empty target_manifest"):
373 apply_manifest(root, {"file.txt": oid}, {})
374
375
376 # ===========================================================================
377 # III verify_workdir_integrity — utility correctness
378 # ===========================================================================
379
380
381 class TestVerifyWorkdirIntegrityIII:
382 """verify_workdir_integrity must catch every form of workdir drift."""
383
384 def test_III1_clean_workdir_returns_empty_list(
385 self, tmp_path: pathlib.Path
386 ) -> None:
387 """III1: workdir matches manifest → no mismatches."""
388 root, _ = _init_repo(tmp_path)
389 oid = _write_disk(root, "a.py", b"x = 1\n")
390 mismatches = verify_workdir_integrity(root, {"a.py": oid})
391 assert mismatches == []
392
393 def test_III2_modified_file_detected(self, tmp_path: pathlib.Path) -> None:
394 """III2: externally modified file shows up as mismatch."""
395 root, _ = _init_repo(tmp_path)
396 original = b"original\n"
397 oid = _write_disk(root, "f.py", original)
398
399 (root / "f.py").write_bytes(b"tampered\n")
400 mismatches = verify_workdir_integrity(root, {"f.py": oid})
401 assert len(mismatches) == 1
402 path, expected, actual = mismatches[0]
403 assert path == "f.py"
404 assert expected == oid
405 assert actual != oid
406 assert actual is not None
407
408 def test_III3_missing_file_detected(self, tmp_path: pathlib.Path) -> None:
409 """III3: file present in manifest but absent from disk → mismatch."""
410 root, _ = _init_repo(tmp_path)
411 oid = _write_disk(root, "gone.py", b"gone\n")
412 (root / "gone.py").unlink()
413 mismatches = verify_workdir_integrity(root, {"gone.py": oid})
414 assert any(m[0] == "gone.py" and m[2] is None for m in mismatches)
415
416 def test_III4_extra_tracked_file_detected(
417 self, tmp_path: pathlib.Path
418 ) -> None:
419 """III4: file on disk but not in manifest is also reported."""
420 root, _ = _init_repo(tmp_path)
421 oid = _write_disk(root, "tracked.py", b"ok\n")
422 (root / "extra.py").write_bytes(b"extra\n")
423 mismatches = verify_workdir_integrity(root, {"tracked.py": oid})
424 extras = [m for m in mismatches if m[0] == "extra.py"]
425 assert extras, "Extra file not reported"
426
427 def test_III5_empty_manifest_empty_workdir_clean(
428 self, tmp_path: pathlib.Path
429 ) -> None:
430 """III5: both manifest and workdir empty → clean."""
431 root, _ = _init_repo(tmp_path)
432 assert verify_workdir_integrity(root, {}) == []
433
434 def test_III6_multiple_mismatches_all_reported(
435 self, tmp_path: pathlib.Path
436 ) -> None:
437 """III6: all mismatches returned, not just the first."""
438 root, _ = _init_repo(tmp_path)
439 manifest: Manifest = {}
440 for i in range(20):
441 oid = _write_disk(root, f"f{i}.py", f"v={i}\n".encode())
442 manifest[f"f{i}.py"] = oid
443
444 # Tamper with half the files
445 for i in range(0, 20, 2):
446 (root / f"f{i}.py").write_bytes(b"tampered\n")
447
448 mismatches = verify_workdir_integrity(root, manifest)
449 assert len(mismatches) == 10, f"Expected 10 mismatches, got {len(mismatches)}"
450
451 def test_III7_correct_content_after_apply_manifest(
452 self, tmp_path: pathlib.Path
453 ) -> None:
454 """III7: after apply_manifest, verify_workdir_integrity is clean."""
455 root, _ = _init_repo(tmp_path)
456 oid_a = _write_disk(root, "a.py", b"a = 1\n")
457 oid_b = _write_disk(root, "b.py", b"b = 2\n")
458 (root / "a.py").unlink()
459 (root / "b.py").unlink()
460
461 apply_manifest(root, {}, {"a.py": oid_a, "b.py": oid_b})
462 mismatches = verify_workdir_integrity(root, {"a.py": oid_a, "b.py": oid_b})
463 assert mismatches == [], f"Expected clean after apply_manifest: {mismatches}"
464
465
466 # ===========================================================================
467 # IV checkout → workdir always matches the target snapshot
468 # ===========================================================================
469
470
471 class TestCheckoutWorkdirIntegrityIV:
472 """After muse checkout, the working tree must byte-for-byte match
473 the target branch's committed snapshot. No exceptions."""
474
475 def _full_roundtrip(self, tmp_path: pathlib.Path, n_files: int) -> None:
476 """Create two branches with different content, checkout between them,
477 verify integrity on each switch."""
478 root, repo_id = _init_repo(tmp_path)
479 code, _ = _run(root, "init", str(root))
480
481 main_manifest: Manifest = {}
482 for i in range(n_files):
483 oid = _write_disk(root, f"src/m{i}.py", f"# main {i}\n".encode())
484 main_manifest[f"src/m{i}.py"] = oid
485 code, out = _run(root, "commit", "--allow-empty", "-m", "main files")
486 assert code == 0, out
487
488 code, out = _run(root, "branch", "feat")
489 assert code == 0, out
490 code, out = _run(root, "checkout", "feat")
491 assert code == 0, out
492
493 feat_manifest: Manifest = {}
494 for i in range(n_files):
495 oid = _write_disk(root, f"src/f{i}.py", f"# feat {i}\n".encode())
496 feat_manifest[f"src/f{i}.py"] = oid
497 code, out = _run(root, "commit", "-m", "feat files")
498 assert code == 0, out
499
500 # Switch back to main — verify clean
501 code, out = _run(root, "checkout", "main")
502 assert code == 0, out
503 main_snap = _head_manifest(root, "main")
504 mismatches = verify_workdir_integrity(root, main_snap)
505 assert mismatches == [], (
506 f"DATA LOSS: {len(mismatches)} mismatch(es) after checkout main:\n"
507 f"{'\n'.join(f' {m}' for m in mismatches[:5])}"
508 )
509
510 # Switch to feat — verify clean
511 code, out = _run(root, "checkout", "feat")
512 assert code == 0, out
513 feat_snap = _head_manifest(root, "feat")
514 mismatches = verify_workdir_integrity(root, feat_snap)
515 assert mismatches == [], (
516 f"DATA LOSS: {len(mismatches)} mismatch(es) after checkout feat:\n"
517 f"{'\n'.join(f' {m}' for m in mismatches[:5])}"
518 )
519
520 def test_IV1_checkout_10_files_workdir_matches_snapshot(
521 self, tmp_path: pathlib.Path
522 ) -> None:
523 """IV1: 10-file repo checkout — workdir matches target snapshot."""
524 self._full_roundtrip(tmp_path, 10)
525
526 def test_IV2_checkout_50_files_workdir_matches_snapshot(
527 self, tmp_path: pathlib.Path
528 ) -> None:
529 """IV2: 50-file repo checkout — workdir matches target snapshot."""
530 self._full_roundtrip(tmp_path, 50)
531
532 def test_IV3_repeated_checkout_workdir_consistent(
533 self, tmp_path: pathlib.Path
534 ) -> None:
535 """IV3: switching back and forth 10 times never corrupts the workdir."""
536 root, repo_id = _init_repo(tmp_path)
537 _run(root, "init", str(root))
538
539 oid_a = _write_disk(root, "f.py", b"version_a\n")
540 _run(root, "commit", "--allow-empty", "-m", "main")
541 _run(root, "branch", "feat")
542 _run(root, "checkout", "feat")
543 oid_b = _write_disk(root, "f.py", b"version_b\n")
544 _run(root, "commit", "-m", "feat")
545
546 main_snap = _head_manifest(root, "main")
547 feat_snap = _head_manifest(root, "feat")
548
549 for i in range(10):
550 branch = "main" if i % 2 == 0 else "feat"
551 _run(root, "checkout", branch)
552 expected = main_snap if branch == "main" else feat_snap
553 mismatches = verify_workdir_integrity(root, expected)
554 assert mismatches == [], (
555 f"Iteration {i}: mismatch after checkout {branch}: {mismatches}"
556 )
557
558 def test_IV4_checkout_restores_file_modified_between_branches(
559 self, tmp_path: pathlib.Path
560 ) -> None:
561 """IV4: a file modified on one branch is correctly restored when
562 switching to a branch that has the original version."""
563 root, repo_id = _init_repo(tmp_path)
564 _run(root, "init", str(root))
565
566 oid_v1 = _write_disk(root, "shared.py", b"# v1\n")
567 _run(root, "commit", "--allow-empty", "-m", "base")
568 _run(root, "branch", "feat")
569
570 oid_v2 = _write_disk(root, "shared.py", b"# v2\n")
571 _run(root, "commit", "-m", "main v2")
572
573 _run(root, "checkout", "feat")
574 assert (root / "shared.py").read_bytes() == b"# v1\n", (
575 "feat branch should have v1 of shared.py"
576 )
577
578 _run(root, "checkout", "main")
579 assert (root / "shared.py").read_bytes() == b"# v2\n", (
580 "main branch should have v2 of shared.py"
581 )
582
583 def test_IV5_checkout_deletes_files_not_in_target_branch(
584 self, tmp_path: pathlib.Path
585 ) -> None:
586 """IV5: files added on one branch are deleted when switching away."""
587 root, repo_id = _init_repo(tmp_path)
588 _run(root, "init", str(root))
589
590 _write_disk(root, "base.py", b"base\n")
591 _run(root, "commit", "--allow-empty", "-m", "base")
592 _run(root, "branch", "feat")
593 _run(root, "checkout", "feat")
594
595 _write_disk(root, "feat_only.py", b"feat\n")
596 _run(root, "commit", "-m", "feat_only")
597
598 _run(root, "checkout", "main")
599 assert not (root / "feat_only.py").exists(), (
600 "feat-only file should not exist on main branch"
601 )
602
603
604 # ===========================================================================
605 # V fast-forward merge → workdir always matches target snapshot
606 # ===========================================================================
607
608
609 class TestFFMergeWorkdirIntegrityV:
610 """A fast-forward merge must update the working tree to match the
611 incoming branch's snapshot — ALL files, not just the delta."""
612
613 def test_V1_ff_merge_all_files_restored(self, tmp_path: pathlib.Path) -> None:
614 """V1: after FF merge every file in the target manifest is on disk
615 with the correct content."""
616 root, repo_id = _init_repo(tmp_path)
617 _run(root, "init", str(root))
618
619 _write_disk(root, "base.py", b"base\n")
620 _run(root, "commit", "--allow-empty", "-m", "base")
621 _run(root, "branch", "feat")
622 _run(root, "checkout", "feat")
623
624 manifest: Manifest = {}
625 for i in range(30):
626 oid = _write_disk(root, f"f{i}.py", f"# feat {i}\n".encode())
627 manifest[f"f{i}.py"] = oid
628 _run(root, "commit", "-m", "feat 30 files")
629
630 _run(root, "checkout", "main")
631 code, out = _run(root, "merge", "feat")
632 assert code == 0, out
633
634 merged = _head_manifest(root, "main")
635 mismatches = verify_workdir_integrity(root, merged)
636 assert mismatches == [], (
637 f"DATA LOSS after FF merge: {len(mismatches)} mismatch(es)\n"
638 f"{'\n'.join(f' {m}' for m in mismatches[:5])}"
639 )
640
641 def test_V2_ff_merge_correct_content_not_just_present(
642 self, tmp_path: pathlib.Path
643 ) -> None:
644 """V2: FF merge writes the correct *content*, not just the correct filename."""
645 root, repo_id = _init_repo(tmp_path)
646 _run(root, "init", str(root))
647
648 old_content = b"# old version\n"
649 new_content = b"# new version\n"
650
651 _write_disk(root, "important.py", old_content)
652 _run(root, "commit", "--allow-empty", "-m", "base")
653 _run(root, "branch", "feat")
654 _run(root, "checkout", "feat")
655
656 _write_disk(root, "important.py", new_content)
657 _run(root, "commit", "-m", "update important.py")
658
659 _run(root, "checkout", "main")
660 # File on disk is now old_content
661 assert (root / "important.py").read_bytes() == old_content
662
663 _run(root, "merge", "feat")
664 # File on disk must now be new_content
665 actual = (root / "important.py").read_bytes()
666 assert actual == new_content, (
667 f"FF merge did not restore correct content. "
668 f"Expected {new_content!r}, got {actual!r}"
669 )
670
671 def test_V3_ff_merge_workdir_matches_snapshot_byte_for_byte(
672 self, tmp_path: pathlib.Path
673 ) -> None:
674 """V3: verify_workdir_integrity confirms zero drift after FF merge."""
675 root, repo_id = _init_repo(tmp_path)
676 _run(root, "init", str(root))
677
678 _write_disk(root, "a.py", b"a\n")
679 _run(root, "commit", "--allow-empty", "-m", "base")
680 _run(root, "branch", "feat")
681 _run(root, "checkout", "feat")
682
683 for i in range(20):
684 _write_disk(root, f"feat_{i}.py", f"feat{i}\n".encode())
685 _run(root, "commit", "-m", "feat 20 files")
686
687 _run(root, "checkout", "main")
688 _run(root, "merge", "feat")
689
690 merged_snap = _head_manifest(root, "main")
691 mismatches = verify_workdir_integrity(root, merged_snap)
692 assert mismatches == []
693
694
695 # ===========================================================================
696 # VI checkout aborts hard when an object is missing from the store
697 # ===========================================================================
698
699
700 class TestCheckoutMissingObjectVI:
701 """Checkout must refuse to proceed when an object it needs is absent
702 from the local object store. The partial-checkout silent-data-loss
703 path is now closed."""
704
705 def test_VI1_checkout_to_branch_with_missing_object_aborts(
706 self, tmp_path: pathlib.Path
707 ) -> None:
708 """VI1: if a required object is purged from the store, checkout
709 exits non-zero and does NOT silently leave the workdir in a
710 partially restored state."""
711 root, repo_id = _init_repo(tmp_path)
712 _run(root, "init", str(root))
713
714 _write_disk(root, "base.py", b"base\n")
715 _run(root, "commit", "--allow-empty", "-m", "base")
716 _run(root, "branch", "feat")
717 _run(root, "checkout", "feat")
718
719 content = b"# feat file\n"
720 oid = _write_disk(root, "feat.py", content)
721 _run(root, "commit", "-m", "feat")
722 _run(root, "checkout", "main")
723
724 # Purge the feat.py object from the store — simulates a corruption
725 obj = _object_path(root, oid)
726 obj.unlink()
727
728 code, out = _run_unchecked(root, "checkout", "feat")
729 assert code != 0, (
730 "Checkout should fail when a required object is missing from the store"
731 )
732
733 def test_VI2_apply_manifest_raises_on_missing_object_not_silent(
734 self, tmp_path: pathlib.Path
735 ) -> None:
736 """VI2: apply_manifest raises RuntimeError (not returns None or logs
737 a warning) when an object is missing."""
738 root, _ = _init_repo(tmp_path)
739 ghost_oid = blob_id(b"not in store")
740 with pytest.raises(RuntimeError) as exc_info:
741 apply_manifest(root, {}, {"ghost.py": ghost_oid})
742 assert "missing from the local store" in str(exc_info.value)
743
744 def test_VI3_ff_merge_aborts_if_incoming_object_missing(
745 self, tmp_path: pathlib.Path
746 ) -> None:
747 """VI3: FF merge aborts if an object from the target snapshot is
748 not in the local store."""
749 root, repo_id = _init_repo(tmp_path)
750 _run(root, "init", str(root))
751
752 _write_disk(root, "base.py", b"base\n")
753 _run(root, "commit", "--allow-empty", "-m", "base")
754 _run(root, "branch", "feat")
755 _run(root, "checkout", "feat")
756
757 content = b"# critical\n"
758 oid = _write_disk(root, "critical.py", content)
759 _run(root, "commit", "-m", "critical")
760 _run(root, "checkout", "main")
761
762 # Purge the object after committing
763 _object_path(root, oid).unlink()
764
765 code, out = _run_unchecked(root, "merge", "feat")
766 assert code != 0, "FF merge should fail when a target object is missing"
767
768
769 # ===========================================================================
770 # VII Editor-cache simulation — status must detect stale-cache workdir drift
771 # ===========================================================================
772
773
774 class TestEditorCacheSimulationVII:
775 """Simulates the exact incident: the editor had a cached (stale) version
776 of a file. After a merge updated the on-disk file, the editor wrote the
777 stale version back, corrupting the workdir.
778
779 Muse cannot prevent an editor from writing stale data, but it CAN detect
780 the drift via `muse status` (which compares workdir hashes against HEAD)
781 and via `verify_workdir_integrity`.
782 """
783
784 def test_VII1_status_detects_workdir_drift_after_external_write(
785 self, tmp_path: pathlib.Path
786 ) -> None:
787 """VII1: if a file is externally overwritten to an old version,
788 muse status reports it as modified."""
789 root, repo_id = _init_repo(tmp_path)
790 _run(root, "init", str(root))
791
792 old_content = b"# version 1\n"
793 new_content = b"# version 2\n"
794
795 _write_disk(root, "plugin.py", old_content)
796 _run(root, "commit", "--allow-empty", "-m", "v1")
797 _run(root, "branch", "feat")
798 _run(root, "checkout", "feat")
799
800 _write_disk(root, "plugin.py", new_content)
801 _run(root, "commit", "-m", "v2")
802 _run(root, "checkout", "main")
803 _run(root, "merge", "feat")
804
805 # HEAD now says plugin.py = new_content.
806 # Simulate editor writing back the stale old version.
807 (root / "plugin.py").write_bytes(old_content)
808
809 code, out = _run(root, "status")
810 assert code == 0
811 assert "plugin.py" in out, (
812 "muse status must report plugin.py as modified after stale write"
813 )
814
815 def test_VII2_verify_workdir_integrity_catches_stale_editor_write(
816 self, tmp_path: pathlib.Path
817 ) -> None:
818 """VII2: verify_workdir_integrity spots the stale-editor-cache corruption."""
819 root, repo_id = _init_repo(tmp_path)
820 _run(root, "init", str(root))
821
822 old_content = b"# old\n"
823 new_content = b"# new\n"
824
825 oid_new = _write_disk(root, "plugin.py", old_content)
826 _run(root, "commit", "--allow-empty", "-m", "base")
827 _run(root, "branch", "feat")
828 _run(root, "checkout", "feat")
829
830 oid_new = _write_disk(root, "plugin.py", new_content)
831 _run(root, "commit", "-m", "feat")
832 _run(root, "checkout", "main")
833 _run(root, "merge", "feat")
834
835 # Simulate editor stale write
836 (root / "plugin.py").write_bytes(old_content)
837
838 head_snap = _head_manifest(root, "main")
839 mismatches = verify_workdir_integrity(root, head_snap)
840 assert any(m[0] == "plugin.py" for m in mismatches), (
841 "verify_workdir_integrity must detect the stale file"
842 )
843
844 def test_VII3_correct_state_after_reapplying_manifest(
845 self, tmp_path: pathlib.Path
846 ) -> None:
847 """VII3: after detecting stale-editor drift, re-applying the manifest
848 restores correct state and verify_workdir_integrity is clean."""
849 root, repo_id = _init_repo(tmp_path)
850 _run(root, "init", str(root))
851
852 _write_disk(root, "plugin.py", b"# old\n")
853 _run(root, "commit", "--allow-empty", "-m", "base")
854 _run(root, "branch", "feat")
855 _run(root, "checkout", "feat")
856
857 _write_disk(root, "plugin.py", b"# new\n")
858 _run(root, "commit", "-m", "feat")
859 _run(root, "checkout", "main")
860 _run(root, "merge", "feat")
861
862 # Stale write
863 (root / "plugin.py").write_bytes(b"# stale\n")
864
865 head_snap = _head_manifest(root, "main")
866 # Repair by re-applying the manifest
867 apply_manifest(root, head_snap, head_snap)
868 mismatches = verify_workdir_integrity(root, head_snap)
869 assert mismatches == [], "After re-applying manifest, workdir must be clean"
870
871
872 # ===========================================================================
873 # VIII Stress tests
874 # ===========================================================================
875
876
877 class TestStressWorkdirVIII:
878 """High-volume, adversarial scenarios to eliminate the entire class of
879 workdir corruption bugs."""
880
881 def test_VIII1_500_file_checkout_all_match_snapshot(
882 self, tmp_path: pathlib.Path
883 ) -> None:
884 """VIII1: 500-file repo — every file matches the committed snapshot
885 after every checkout."""
886 root, repo_id = _init_repo(tmp_path)
887 _run(root, "init", str(root))
888
889 manifest_main: Manifest = {}
890 for i in range(500):
891 oid = _write_disk(root, f"main_{i:04d}.py", f"# main {i}\n".encode())
892 manifest_main[f"main_{i:04d}.py"] = oid
893 code, out = _run(root, "commit", "--allow-empty", "-m", "main 500")
894 assert code == 0, out
895
896 _run(root, "branch", "feat")
897 _run(root, "checkout", "feat")
898
899 manifest_feat: Manifest = {}
900 for i in range(500):
901 oid = _write_disk(root, f"feat_{i:04d}.py", f"# feat {i}\n".encode())
902 manifest_feat[f"feat_{i:04d}.py"] = oid
903 code, out = _run(root, "commit", "-m", "feat 500")
904 assert code == 0, out
905
906 _run(root, "checkout", "main")
907 snp = _head_manifest(root, "main")
908 mismatches = verify_workdir_integrity(root, snp)
909 assert mismatches == [], f"{len(mismatches)} mismatch(es) on main"
910
911 _run(root, "checkout", "feat")
912 snp = _head_manifest(root, "feat")
913 mismatches = verify_workdir_integrity(root, snp)
914 assert mismatches == [], f"{len(mismatches)} mismatch(es) on feat"
915
916 def test_VIII2_ff_merge_500_files_all_correct(
917 self, tmp_path: pathlib.Path
918 ) -> None:
919 """VIII2: FF merge with 500 incoming files — all correct after merge."""
920 root, repo_id = _init_repo(tmp_path)
921 _run(root, "init", str(root))
922
923 _write_disk(root, "base.py", b"base\n")
924 _run(root, "commit", "--allow-empty", "-m", "base")
925 _run(root, "branch", "feat")
926 _run(root, "checkout", "feat")
927
928 expected: Manifest = {}
929 for i in range(500):
930 content = f"# file {i} unique content {content_hash({'i': i})}\n".encode()
931 oid = _write_disk(root, f"feat_{i:04d}.py", content)
932 expected[f"feat_{i:04d}.py"] = oid
933 _run(root, "commit", "-m", "feat 500")
934
935 _run(root, "checkout", "main")
936 code, out = _run(root, "merge", "feat")
937 assert code == 0, out
938
939 merged = _head_manifest(root, "main")
940 mismatches = verify_workdir_integrity(root, merged)
941 assert mismatches == [], (
942 f"DATA LOSS: {len(mismatches)} file(s) wrong after 500-file FF merge"
943 )
944
945 def test_VIII3_alternating_checkout_never_drifts(
946 self, tmp_path: pathlib.Path
947 ) -> None:
948 """VIII3: 20 alternating checkout cycles — workdir never drifts."""
949 root, repo_id = _init_repo(tmp_path)
950 _run(root, "init", str(root))
951
952 oid_a = _write_disk(root, "shared.py", b"# version A\n")
953 _run(root, "commit", "--allow-empty", "-m", "main")
954 snap_main = _head_manifest(root, "main")
955
956 _run(root, "branch", "feat")
957 _run(root, "checkout", "feat")
958 oid_b = _write_disk(root, "shared.py", b"# version B\n")
959 _run(root, "commit", "-m", "feat")
960 snap_feat = _head_manifest(root, "feat")
961
962 for cycle in range(20):
963 branch = "main" if cycle % 2 == 0 else "feat"
964 code, out = _run(root, "checkout", branch)
965 assert code == 0, f"Cycle {cycle}: checkout {branch} failed"
966 expected = snap_main if branch == "main" else snap_feat
967 mismatches = verify_workdir_integrity(root, expected)
968 assert mismatches == [], (
969 f"Cycle {cycle}, branch {branch}: {len(mismatches)} mismatch(es)"
970 )
971
972 def test_VIII4_deep_chain_checkout_base_restores_correctly(
973 self, tmp_path: pathlib.Path
974 ) -> None:
975 """VIII4: deep commit chain — checkout of base commit restores correctly."""
976 root, repo_id = _init_repo(tmp_path)
977 _run(root, "init", str(root))
978
979 # Build a chain of 20 commits, each modifying the same file
980 for i in range(20):
981 _write_disk(root, "evolving.py", f"# iteration {i}\n".encode())
982 _run(root, "commit", "--allow-empty", "-m", f"iter {i}")
983
984 snap = _head_manifest(root, "main")
985 assert (root / "evolving.py").read_bytes() == b"# iteration 19\n"
986 mismatches = verify_workdir_integrity(root, snap)
987 assert mismatches == []
988
989 def test_VIII5_stress_apply_manifest_100_times_deterministic(
990 self, tmp_path: pathlib.Path
991 ) -> None:
992 """VIII5: apply_manifest called 100 times is deterministic and
993 always produces an identical workdir."""
994 root, _ = _init_repo(tmp_path)
995 manifest: Manifest = {}
996 for i in range(50):
997 oid = _write_disk(root, f"f{i}.py", f"content {i}\n".encode())
998 manifest[f"f{i}.py"] = oid
999
1000 for trial in range(100):
1001 apply_manifest(root, manifest, manifest)
1002 mismatches = verify_workdir_integrity(root, manifest)
1003 assert mismatches == [], (
1004 f"Trial {trial}: {len(mismatches)} mismatch(es) after apply_manifest"
1005 )
1006
1007 def test_VIII6_diamond_topology_workdir_clean_after_all_merges(
1008 self, tmp_path: pathlib.Path
1009 ) -> None:
1010 """VIII6: diamond merge topology — verify integrity at every step."""
1011 root, repo_id = _init_repo(tmp_path)
1012 _run(root, "init", str(root))
1013
1014 # Base
1015 _write_disk(root, "base.py", b"base\n")
1016 _run(root, "commit", "--allow-empty", "-m", "base")
1017
1018 # Left branch
1019 _run(root, "branch", "left")
1020 _run(root, "checkout", "left")
1021 _write_disk(root, "left.py", b"left\n")
1022 _run(root, "commit", "-m", "left")
1023
1024 # Right branch (from main)
1025 _run(root, "checkout", "main")
1026 _run(root, "branch", "right")
1027 _run(root, "checkout", "right")
1028 _write_disk(root, "right.py", b"right\n")
1029 _run(root, "commit", "-m", "right")
1030
1031 # Merge left → main
1032 _run(root, "checkout", "main")
1033 code, out = _run(root, "merge", "left")
1034 assert code == 0, out
1035 snp = _head_manifest(root, "main")
1036 assert verify_workdir_integrity(root, snp) == []
1037
1038 # Merge right → main
1039 code, out = _run(root, "merge", "right")
1040 assert code == 0, out
1041 snp = _head_manifest(root, "main")
1042 assert verify_workdir_integrity(root, snp) == []
1043
1044 def test_VIII7_binary_files_survive_checkout(
1045 self, tmp_path: pathlib.Path
1046 ) -> None:
1047 """VIII7: binary content (random bytes) survives checkout intact."""
1048 root, repo_id = _init_repo(tmp_path)
1049 _run(root, "init", str(root))
1050
1051 binary = os.urandom(1024 * 512) # 512 KiB of random bytes
1052 oid = _write_disk(root, "data.bin", binary)
1053 _run(root, "commit", "--allow-empty", "-m", "binary")
1054
1055 _run(root, "branch", "feat")
1056 _run(root, "checkout", "feat")
1057 _write_disk(root, "other.py", b"other\n")
1058 _run(root, "commit", "-m", "other")
1059
1060 _run(root, "checkout", "main")
1061 snp = _head_manifest(root, "main")
1062 mismatches = verify_workdir_integrity(root, snp)
1063 assert mismatches == []
1064 assert (root / "data.bin").read_bytes() == binary
1065
1066 def test_VIII8_unicode_filenames_survive_checkout(
1067 self, tmp_path: pathlib.Path
1068 ) -> None:
1069 """VIII8: files with unicode path components survive checkout."""
1070 root, repo_id = _init_repo(tmp_path)
1071 _run(root, "init", str(root))
1072
1073 paths = [
1074 "src/módulo.py",
1075 "src/données.txt",
1076 "src/файл.py",
1077 ]
1078 for p in paths:
1079 _write_disk(root, p, f"# {p}\n".encode())
1080 _run(root, "commit", "--allow-empty", "-m", "unicode paths")
1081
1082 _run(root, "branch", "feat")
1083 _run(root, "checkout", "feat")
1084 _write_disk(root, "extra.py", b"extra\n")
1085 _run(root, "commit", "-m", "extra")
1086
1087 _run(root, "checkout", "main")
1088 snp = _head_manifest(root, "main")
1089 mismatches = verify_workdir_integrity(root, snp)
1090 assert mismatches == []
1091
1092 def test_VIII9_no_data_loss_after_100_consecutive_commits(
1093 self, tmp_path: pathlib.Path
1094 ) -> None:
1095 """VIII9: 100 consecutive commits on main — verify final state is correct."""
1096 root, repo_id = _init_repo(tmp_path)
1097 _run(root, "init", str(root))
1098
1099 for i in range(100):
1100 _write_disk(root, f"file_{i:03d}.py", f"# commit {i}\n".encode())
1101 code, out = _run(root, "commit", "--allow-empty", "-m", f"commit {i}")
1102 assert code == 0, f"Commit {i} failed: {out}"
1103
1104 snp = _head_manifest(root, "main")
1105 assert len(snp) == 100, f"Expected 100 files, got {len(snp)}"
1106 mismatches = verify_workdir_integrity(root, snp)
1107 assert mismatches == [], f"{len(mismatches)} mismatch(es) after 100 commits"
1108
1109
1110 # ===========================================================================
1111 # IX apply_manifest must not delete untracked files
1112 # ===========================================================================
1113
1114
1115 class TestApplyManifestUntrackedFilesIX:
1116 """Untracked files must survive apply_manifest regardless of target manifest.
1117
1118 Root cause of the bug: apply_manifest used walk_workdir(root) to build
1119 current_files, which returns ALL files on disk — including files the user
1120 created that were never committed. The fix: use prev_manifest.keys() as
1121 the deletion candidate set so only previously-tracked files are candidates.
1122 """
1123
1124 def test_IX1_untracked_file_not_deleted_by_apply_manifest(
1125 self, tmp_path: pathlib.Path
1126 ) -> None:
1127 """IX1: a file never in any manifest must survive apply_manifest."""
1128 root, _ = _init_repo(tmp_path)
1129
1130 # prev state: file A is tracked
1131 oid_a = _write_disk(root, "a.py", b"a = 1\n")
1132 # target state: file B is tracked
1133 oid_b = _store_object(root, b"b = 2\n")
1134 (root / "b.py").write_bytes(b"b = 2\n")
1135
1136 # untracked file — never in any manifest
1137 untracked = root / "notes.txt"
1138 untracked.write_bytes(b"my personal notes\n")
1139
1140 apply_manifest(root, {"a.py": oid_a}, {"b.py": oid_b})
1141
1142 assert untracked.exists(), "untracked file was deleted by apply_manifest — data loss bug"
1143 assert (root / "b.py").read_bytes() == b"b = 2\n"
1144 assert not (root / "a.py").exists(), "a.py was tracked then removed — must be deleted"
1145
1146 def test_IX2_untracked_dotfile_not_deleted(
1147 self, tmp_path: pathlib.Path
1148 ) -> None:
1149 """IX2: untracked dotfiles (e.g. spec docs written by Write tool) must survive."""
1150 root, _ = _init_repo(tmp_path)
1151
1152 oid_a = _write_disk(root, "main.py", b"x = 1\n")
1153 oid_b = _store_object(root, b"x = 2\n")
1154 (root / "main.py").write_bytes(b"x = 2\n")
1155
1156 spec_doc = root / "docs" / "spec.md"
1157 spec_doc.parent.mkdir()
1158 spec_doc.write_bytes(b"# spec\n")
1159
1160 apply_manifest(root, {"main.py": oid_a}, {"main.py": oid_b})
1161
1162 assert spec_doc.exists(), "untracked doc file was deleted by apply_manifest"
1163
1164 def test_IX3_tracked_file_removed_from_target_gets_deleted(
1165 self, tmp_path: pathlib.Path
1166 ) -> None:
1167 """IX3: files in prev_manifest but not in target must still be deleted."""
1168 root, _ = _init_repo(tmp_path)
1169
1170 oid_a = _write_disk(root, "gone.py", b"gone\n")
1171 oid_b = _write_disk(root, "kept.py", b"kept\n")
1172
1173 apply_manifest(root, {"gone.py": oid_a, "kept.py": oid_b}, {"kept.py": oid_b})
1174
1175 assert not (root / "gone.py").exists(), "tracked file removed from target must be deleted"
1176 assert (root / "kept.py").exists()
1177
1178 def test_IX4_commit_does_not_delete_untracked_file(
1179 self, tmp_path: pathlib.Path
1180 ) -> None:
1181 """IX4: muse commit must not delete untracked files from the working tree."""
1182 root, repo_id = _init_repo(tmp_path)
1183 _run(root, "init", str(root))
1184
1185 # First commit with one tracked file
1186 _write_disk(root, "tracked.py", b"x = 1\n")
1187 code, out = _run(root, "commit", "-m", "initial")
1188 assert code == 0, out
1189
1190 # Write an untracked file (simulates Write tool creating a spec doc)
1191 untracked = root / "docs" / "spec.md"
1192 untracked.parent.mkdir()
1193 untracked.write_bytes(b"# my spec\n")
1194 assert untracked.exists()
1195
1196 # Modify tracked file and commit
1197 (root / "tracked.py").write_bytes(b"x = 2\n")
1198 code, out = _run(root, "commit", "-m", "update")
1199 assert code == 0, out
1200
1201 assert untracked.exists(), (
1202 "muse commit deleted an untracked file — data loss bug. "
1203 "apply_manifest must not delete files absent from prev_manifest."
1204 )
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 127 days ago