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