gabriel / muse public
test_checkout_integrity.py python
533 lines 25.4 KB
Raw
1 """Tests for checkout data-integrity guarantees.
2
3 Coverage tiers
4 --------------
5 Unit — read_checkout_head / checkout_head_path helpers.
6 Integration — pre-flight aborts cleanly (zero mutations), CHECKOUT_HEAD
7 marker lifecycle, status detection of interrupted checkout.
8 End-to-end — full CLI round-trips: missing object, simulated mid-flight
9 kill (marker left behind), recovery via retry checkout.
10 Stress — rapid branch switching never leaves a stale marker.
11
12 Key invariants under test
13 -------------------------
14 1. Pre-flight: if any restore object is absent from the store, checkout
15 prints an error and does NOT modify any file on disk.
16 2. CHECKOUT_HEAD written before first mutation; removed on success.
17 3. ``muse status`` (text + JSON) detects a stale marker and warns loudly.
18 4. A successful checkout on retry clears the stale marker.
19 5. Interrupted checkout leaves HEAD pointing to the *old* branch.
20 """
21
22 from __future__ import annotations
23
24 import json
25 import os
26 import pathlib
27 import shutil
28
29 import pytest
30
31 from tests.cli_test_helper import CliRunner, InvokeResult
32 from muse.core.types import MsgpackDict
33 from muse.core.refs import (
34 get_head_commit_id,
35 read_current_branch,
36 )
37 from muse.core.object_store import has_object
38 from muse.cli.commands.checkout import checkout_head_path, read_checkout_head
39
40 runner = CliRunner()
41
42 # ──────────────────────────────────────────────────────────────────────────────
43 # Helpers
44 # ──────────────────────────────────────────────────────────────────────────────
45
46
47 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
48 saved = os.getcwd()
49 try:
50 os.chdir(repo)
51 return runner.invoke(None, args)
52 finally:
53 os.chdir(saved)
54
55
56 def _commit(repo: pathlib.Path, msg: str = "commit") -> InvokeResult:
57 return _invoke(repo, ["commit", "-m", msg])
58
59
60 def _checkout(repo: pathlib.Path, branch: str, *flags: str) -> InvokeResult:
61 return _invoke(repo, ["checkout", *flags, branch])
62
63
64 def _status_json(repo: pathlib.Path) -> MsgpackDict:
65 r = _invoke(repo, ["status", "--json"])
66 return json.loads(r.output)
67
68
69 def _status_text(repo: pathlib.Path) -> tuple[str, str]:
70 """Return (stdout, stderr) of muse status."""
71 r = _invoke(repo, ["status"])
72 return r.output, (r.stderr or "")
73
74
75 # ──────────────────────────────────────────────────────────────────────────────
76 # Fixtures
77 # ──────────────────────────────────────────────────────────────────────────────
78
79
80 @pytest.fixture()
81 def two_branch_repo(tmp_path: pathlib.Path) -> pathlib.Path:
82 """Repo with main and feat branches, each with distinct files.
83
84 main: a.py
85 feat: a.py (unchanged) + b.py (feat-only)
86 """
87 saved = os.getcwd()
88 try:
89 os.chdir(tmp_path)
90 runner.invoke(None, ["init"])
91 finally:
92 os.chdir(saved)
93 (tmp_path / "a.py").write_text("x = 1\n")
94 _commit(tmp_path, "main initial")
95 _invoke(tmp_path, ["checkout", "-b", "feat"])
96 (tmp_path / "b.py").write_text("y = 2\n")
97 _commit(tmp_path, "feat adds b.py")
98 _checkout(tmp_path, "main")
99 return tmp_path
100
101
102 # ──────────────────────────────────────────────────────────────────────────────
103 # Unit: helper functions
104 # ──────────────────────────────────────────────────────────────────────────────
105
106
107 class TestCheckoutHeadHelpers:
108 def test_checkout_head_path_points_into_muse_dir(self, two_branch_repo: pathlib.Path) -> None:
109 p = checkout_head_path(two_branch_repo)
110 assert p.parent == two_branch_repo / ".muse"
111 assert p.name == "CHECKOUT_HEAD"
112
113 def test_read_checkout_head_returns_none_when_absent(self, two_branch_repo: pathlib.Path) -> None:
114 assert read_checkout_head(two_branch_repo) is None
115
116 def test_read_checkout_head_returns_content_when_present(self, two_branch_repo: pathlib.Path) -> None:
117 marker = checkout_head_path(two_branch_repo)
118 marker.write_text("feat\n", encoding="utf-8")
119 assert read_checkout_head(two_branch_repo) == "feat"
120 marker.unlink()
121
122 def test_read_checkout_head_strips_trailing_newline(self, two_branch_repo: pathlib.Path) -> None:
123 marker = checkout_head_path(two_branch_repo)
124 marker.write_text("feat\n\n", encoding="utf-8")
125 assert read_checkout_head(two_branch_repo) == "feat"
126 marker.unlink()
127
128 def test_read_checkout_head_empty_file_returns_none(self, two_branch_repo: pathlib.Path) -> None:
129 marker = checkout_head_path(two_branch_repo)
130 marker.write_text("", encoding="utf-8")
131 assert read_checkout_head(two_branch_repo) is None
132 marker.unlink()
133
134
135 # ──────────────────────────────────────────────────────────────────────────────
136 # Integration: pre-flight object existence check
137 # ──────────────────────────────────────────────────────────────────────────────
138
139
140 class TestPreflightObjectCheck:
141 """Pre-flight: abort before any mutation if a restore object is missing."""
142
143 def _sabotage_object(self, repo: pathlib.Path, rel_path: str) -> pathlib.Path:
144 """Remove the object backing *rel_path* from the store; return its path."""
145 from muse.core.refs import get_head_commit_id
146 from muse.core.commits import read_commit
147 from muse.core.snapshots import read_snapshot
148 from muse.core.object_store import _object_path_with_fallback
149
150 # Switch to feat first so its snapshot is current, then read its manifest
151 _checkout(repo, "feat")
152 branch = read_current_branch(repo)
153 commit_id = get_head_commit_id(repo, branch)
154 assert commit_id
155 commit = read_commit(repo, commit_id)
156 assert commit
157 snap = read_snapshot(repo, commit.snapshot_id)
158 assert snap
159 obj_id = snap.manifest[rel_path]
160 obj_path = _object_path_with_fallback(repo, obj_id)
161 assert obj_path.exists(), f"Object for {rel_path} not in store"
162 # Back on main before sabotaging
163 _checkout(repo, "main")
164 # Now remove the object
165 obj_path.unlink()
166 return obj_path
167
168 def test_missing_object_exits_nonzero(self, two_branch_repo: pathlib.Path) -> None:
169 self._sabotage_object(two_branch_repo, "b.py")
170 result = _checkout(two_branch_repo, "feat")
171 assert result.exit_code != 0
172
173 def test_missing_object_error_on_stderr(self, two_branch_repo: pathlib.Path) -> None:
174 self._sabotage_object(two_branch_repo, "b.py")
175 result = _checkout(two_branch_repo, "feat")
176 err = result.stderr or result.output
177 assert "missing" in err.lower() or "object" in err.lower()
178
179 def test_missing_object_working_tree_unchanged(self, two_branch_repo: pathlib.Path) -> None:
180 """Zero mutations — b.py must not appear on main after a failed checkout."""
181 self._sabotage_object(two_branch_repo, "b.py")
182 _checkout(two_branch_repo, "feat")
183 # Working tree must not contain b.py — no partial mutations
184 assert not (two_branch_repo / "b.py").exists()
185
186 def test_missing_object_a_py_unchanged(self, two_branch_repo: pathlib.Path) -> None:
187 """Files that would be kept unchanged must also remain untouched."""
188 self._sabotage_object(two_branch_repo, "b.py")
189 _checkout(two_branch_repo, "feat")
190 # a.py was already on main and unchanged; must still be present
191 assert (two_branch_repo / "a.py").exists()
192
193 def test_missing_object_head_stays_on_old_branch(self, two_branch_repo: pathlib.Path) -> None:
194 """HEAD must not advance when pre-flight aborts."""
195 self._sabotage_object(two_branch_repo, "b.py")
196 _checkout(two_branch_repo, "feat")
197 assert read_current_branch(two_branch_repo) == "main"
198
199 def test_missing_object_no_checkout_head_marker(self, two_branch_repo: pathlib.Path) -> None:
200 """Pre-flight abort fires before any marker is written."""
201 self._sabotage_object(two_branch_repo, "b.py")
202 _checkout(two_branch_repo, "feat")
203 # Marker must NOT be present — pre-flight aborted before any mutation
204 assert read_checkout_head(two_branch_repo) is None
205
206 def test_stderr_names_the_missing_file(self, two_branch_repo: pathlib.Path) -> None:
207 self._sabotage_object(two_branch_repo, "b.py")
208 result = _checkout(two_branch_repo, "feat")
209 err = result.stderr or result.output
210 assert "b.py" in err
211
212 def test_stderr_says_working_tree_not_modified(self, two_branch_repo: pathlib.Path) -> None:
213 self._sabotage_object(two_branch_repo, "b.py")
214 result = _checkout(two_branch_repo, "feat")
215 err = result.stderr or result.output
216 assert "NOT modified" in err or "not modified" in err.lower()
217
218
219 # ──────────────────────────────────────────────────────────────────────────────
220 # Integration: CHECKOUT_HEAD marker lifecycle
221 # ──────────────────────────────────────────────────────────────────────────────
222
223
224 class TestCheckoutHeadMarker:
225 """CHECKOUT_HEAD written before mutations, cleared after success."""
226
227 def test_marker_absent_after_clean_checkout(self, two_branch_repo: pathlib.Path) -> None:
228 _checkout(two_branch_repo, "feat")
229 assert read_checkout_head(two_branch_repo) is None
230
231 def test_marker_absent_after_round_trip(self, two_branch_repo: pathlib.Path) -> None:
232 _checkout(two_branch_repo, "feat")
233 _checkout(two_branch_repo, "main")
234 assert read_checkout_head(two_branch_repo) is None
235
236 def test_stale_marker_survives_failed_checkout(self, two_branch_repo: pathlib.Path) -> None:
237 """Manually plant a stale marker; it must persist (not auto-cleared)."""
238 marker = checkout_head_path(two_branch_repo)
239 marker.write_text("feat\n", encoding="utf-8")
240 # Successful checkout to main should clear it
241 _checkout(two_branch_repo, "main")
242 # main is already current, but checkout still fires the snapshot path
243 # which should clear the marker on success
244 # (Already on main — 'already_on' path does NOT call _checkout_snapshot,
245 # so the marker is unaffected. Plant while NOT on main.)
246 # Reset: switch to feat first, plant marker, switch back
247 _checkout(two_branch_repo, "feat")
248 marker.write_text("main\n", encoding="utf-8")
249 _checkout(two_branch_repo, "main")
250 assert read_checkout_head(two_branch_repo) is None
251
252 def test_simulated_interrupted_marker_persists(self, two_branch_repo: pathlib.Path) -> None:
253 """Simulate kill mid-checkout: plant marker, verify it stays."""
254 marker = checkout_head_path(two_branch_repo)
255 marker.write_text("feat\n", encoding="utf-8")
256 # Do not run checkout — marker lingers
257 assert read_checkout_head(two_branch_repo) == "feat"
258 marker.unlink()
259
260 def test_retry_checkout_clears_stale_marker(self, two_branch_repo: pathlib.Path) -> None:
261 """Retry of the interrupted checkout must clear the marker."""
262 # Simulate interrupted checkout of feat (marker left behind,
263 # b.py not restored)
264 marker = checkout_head_path(two_branch_repo)
265 marker.write_text("feat\n", encoding="utf-8")
266 # Retry — should succeed and clear marker
267 result = _checkout(two_branch_repo, "feat")
268 assert result.exit_code == 0
269 assert read_checkout_head(two_branch_repo) is None
270
271 def test_marker_records_target_branch_name(self, two_branch_repo: pathlib.Path) -> None:
272 """After a successful checkout the marker is gone; its content was the target."""
273 # We can't easily inspect the marker mid-flight without mocking,
274 # but we can write it ourselves and verify read_checkout_head returns it.
275 marker = checkout_head_path(two_branch_repo)
276 marker.write_text("feat\n")
277 assert read_checkout_head(two_branch_repo) == "feat"
278 marker.unlink()
279
280
281 # ──────────────────────────────────────────────────────────────────────────────
282 # Integration: muse status detects interrupted checkout
283 # ──────────────────────────────────────────────────────────────────────────────
284
285
286 class TestStatusDetectsInterruptedCheckout:
287 """muse status warns loudly when CHECKOUT_HEAD exists."""
288
289 def test_json_checkout_interrupted_false_normally(self, two_branch_repo: pathlib.Path) -> None:
290 data = _status_json(two_branch_repo)
291 assert data["checkout_interrupted"] is False
292
293 def test_json_checkout_target_null_normally(self, two_branch_repo: pathlib.Path) -> None:
294 data = _status_json(two_branch_repo)
295 assert data["checkout_target"] is None
296
297 def test_json_checkout_interrupted_true_with_marker(self, two_branch_repo: pathlib.Path) -> None:
298 marker = checkout_head_path(two_branch_repo)
299 marker.write_text("feat\n")
300 try:
301 data = _status_json(two_branch_repo)
302 assert data["checkout_interrupted"] is True
303 finally:
304 marker.unlink(missing_ok=True)
305
306 def test_json_checkout_target_set_with_marker(self, two_branch_repo: pathlib.Path) -> None:
307 marker = checkout_head_path(two_branch_repo)
308 marker.write_text("feat\n")
309 try:
310 data = _status_json(two_branch_repo)
311 assert data["checkout_target"] == "feat"
312 finally:
313 marker.unlink(missing_ok=True)
314
315 def test_json_keys_always_present(self, two_branch_repo: pathlib.Path) -> None:
316 data = _status_json(two_branch_repo)
317 assert "checkout_interrupted" in data
318 assert "checkout_target" in data
319
320 def test_text_status_warns_on_interrupted_checkout(self, two_branch_repo: pathlib.Path) -> None:
321 marker = checkout_head_path(two_branch_repo)
322 marker.write_text("feat\n")
323 try:
324 _, stderr = _status_text(two_branch_repo)
325 assert "CHECKOUT INTERRUPTED" in stderr or "CHECKOUT INTERRUPTED" in stderr.upper()
326 finally:
327 marker.unlink(missing_ok=True)
328
329 def test_text_status_names_target_branch(self, two_branch_repo: pathlib.Path) -> None:
330 marker = checkout_head_path(two_branch_repo)
331 marker.write_text("feat\n")
332 try:
333 _, stderr = _status_text(two_branch_repo)
334 assert "feat" in stderr
335 finally:
336 marker.unlink(missing_ok=True)
337
338 def test_text_status_mentions_retry_command(self, two_branch_repo: pathlib.Path) -> None:
339 marker = checkout_head_path(two_branch_repo)
340 marker.write_text("feat\n")
341 try:
342 _, stderr = _status_text(two_branch_repo)
343 assert "muse checkout" in stderr
344 finally:
345 marker.unlink(missing_ok=True)
346
347 def test_text_status_clean_no_warning(self, two_branch_repo: pathlib.Path) -> None:
348 _, stderr = _status_text(two_branch_repo)
349 assert "CHECKOUT INTERRUPTED" not in stderr
350
351
352 # ──────────────────────────────────────────────────────────────────────────────
353 # End-to-end: full recovery flow
354 # ──────────────────────────────────────────────────────────────────────────────
355
356
357 class TestCheckoutIntegrityE2E:
358 """Full end-to-end scenarios for the checkout integrity system."""
359
360 def test_successful_checkout_leaves_clean_status(self, two_branch_repo: pathlib.Path) -> None:
361 _checkout(two_branch_repo, "feat")
362 data = _status_json(two_branch_repo)
363 assert data["checkout_interrupted"] is False
364 assert data["checkout_target"] is None
365 assert data["clean"] is True
366
367 def test_interrupted_checkout_then_status_then_retry(self, two_branch_repo: pathlib.Path) -> None:
368 """Full recovery flow: interrupt → status warns → retry → clean."""
369 # Simulate interruption: plant marker and delete b.py as if
370 # the checkout partially ran (deleted files but didn't restore yet)
371 marker = checkout_head_path(two_branch_repo)
372 marker.write_text("feat\n")
373
374 # status should warn
375 data = _status_json(two_branch_repo)
376 assert data["checkout_interrupted"] is True
377
378 # retry the checkout — should succeed and clean up
379 result = _checkout(two_branch_repo, "feat")
380 assert result.exit_code == 0
381
382 # marker gone, status clean
383 assert read_checkout_head(two_branch_repo) is None
384 data2 = _status_json(two_branch_repo)
385 assert data2["checkout_interrupted"] is False
386 assert data2["checkout_target"] is None
387
388 def test_interrupted_checkout_head_unchanged(self, two_branch_repo: pathlib.Path) -> None:
389 """HEAD must still point to the old branch after a simulated interruption."""
390 # On main, plant a marker pretending we were switching to feat
391 marker = checkout_head_path(two_branch_repo)
392 marker.write_text("feat\n")
393 # HEAD has not changed — we only wrote the marker, didn't run checkout
394 assert read_current_branch(two_branch_repo) == "main"
395 marker.unlink()
396
397 def test_b_py_present_after_recovery_checkout(self, two_branch_repo: pathlib.Path) -> None:
398 """After successful retry checkout to feat, feat-only file must exist."""
399 # First do a clean checkout to feat to confirm it works
400 result = _checkout(two_branch_repo, "feat")
401 assert result.exit_code == 0
402 assert (two_branch_repo / "b.py").exists()
403
404 def test_b_py_absent_after_checkout_back_to_main(self, two_branch_repo: pathlib.Path) -> None:
405 """After checking back out to main, feat-only file must be gone."""
406 _checkout(two_branch_repo, "feat")
407 result = _checkout(two_branch_repo, "main")
408 assert result.exit_code == 0
409 assert not (two_branch_repo / "b.py").exists()
410
411 def test_preflight_then_retry_with_restored_object(
412 self, two_branch_repo: pathlib.Path
413 ) -> None:
414 """Remove an object, verify clean abort, restore object, verify checkout succeeds."""
415 from muse.core.refs import get_head_commit_id
416 from muse.core.commits import read_commit
417 from muse.core.snapshots import read_snapshot
418 from muse.core.object_store import _object_path_with_fallback
419
420 # Read the object ID for b.py from the feat snapshot
421 _checkout(two_branch_repo, "feat")
422 commit_id = get_head_commit_id(two_branch_repo, "feat")
423 commit = read_commit(two_branch_repo, commit_id)
424 snap = read_snapshot(two_branch_repo, commit.snapshot_id)
425 obj_id = snap.manifest["b.py"]
426 obj_path = _object_path_with_fallback(two_branch_repo, obj_id)
427 # Save contents before sabotage
428 saved = obj_path.read_bytes()
429
430 _checkout(two_branch_repo, "main")
431 obj_path.unlink()
432
433 # Pre-flight fails cleanly
434 result = _checkout(two_branch_repo, "feat")
435 assert result.exit_code != 0
436 assert not (two_branch_repo / "b.py").exists()
437 assert read_current_branch(two_branch_repo) == "main"
438
439 # Restore the object (simulating a fetch)
440 obj_path.write_bytes(saved)
441
442 # Retry succeeds
443 result2 = _checkout(two_branch_repo, "feat")
444 assert result2.exit_code == 0
445 assert (two_branch_repo / "b.py").exists()
446 assert read_current_branch(two_branch_repo) == "feat"
447
448
449 # ──────────────────────────────────────────────────────────────────────────────
450 # Stress: rapid switching never leaves a stale marker
451 # ──────────────────────────────────────────────────────────────────────────────
452
453
454 class TestCheckoutIntegrityStress:
455 def test_rapid_switching_no_stale_marker(self, two_branch_repo: pathlib.Path) -> None:
456 """Switching branches 50 times must never leave CHECKOUT_HEAD behind."""
457 for i in range(25):
458 r1 = _checkout(two_branch_repo, "feat")
459 assert r1.exit_code == 0, f"iter {i}: checkout feat failed"
460 assert read_checkout_head(two_branch_repo) is None, f"iter {i}: marker after feat checkout"
461 r2 = _checkout(two_branch_repo, "main")
462 assert r2.exit_code == 0, f"iter {i}: checkout main failed"
463 assert read_checkout_head(two_branch_repo) is None, f"iter {i}: marker after main checkout"
464
465 def test_status_json_schema_stable_across_branches(self, two_branch_repo: pathlib.Path) -> None:
466 """checkout_interrupted and checkout_target always present in JSON output."""
467 required = {"checkout_interrupted", "checkout_target"}
468 for branch in ("feat", "main", "feat", "main"):
469 _checkout(two_branch_repo, branch)
470 data = _status_json(two_branch_repo)
471 missing = required - data.keys()
472 assert not missing, f"Missing keys after checkout to {branch}: {missing}"
473
474
475 # ──────────────────────────────────────────────────────────────────────────────
476 # Regression: checkout must update HEAD to the target branch
477 # ──────────────────────────────────────────────────────────────────────────────
478
479
480 class TestCheckoutHeadUpdate:
481 """Regression tests for the _ref_path-not-imported bug.
482
483 Previously, checkout exited with code 1 (NameError on _ref_path) and left
484 HEAD pointing at the old branch. Every subsequent commit then landed on
485 the wrong branch. These tests pin the invariant that a successful checkout
486 always updates HEAD.
487 """
488
489 def test_checkout_updates_head_to_target_branch(
490 self, two_branch_repo: pathlib.Path
491 ) -> None:
492 """Switching to feat must update HEAD; switching back must restore main."""
493 assert read_current_branch(two_branch_repo) == "main"
494
495 result = _checkout(two_branch_repo, "feat")
496 assert result.exit_code == 0, f"checkout feat failed: {result.output}"
497 assert read_current_branch(two_branch_repo) == "feat", (
498 "HEAD must point to feat after successful checkout"
499 )
500
501 result = _checkout(two_branch_repo, "main")
502 assert result.exit_code == 0, f"checkout main failed: {result.output}"
503 assert read_current_branch(two_branch_repo) == "main", (
504 "HEAD must point to main after switching back"
505 )
506
507 def test_commit_after_checkout_lands_on_correct_branch(
508 self, two_branch_repo: pathlib.Path
509 ) -> None:
510 """A commit made after checkout must advance the target branch tip, not main."""
511 from muse.core.refs import get_head_commit_id
512
513 tip_main_before = get_head_commit_id(two_branch_repo, "main")
514
515 result = _checkout(two_branch_repo, "feat")
516 assert result.exit_code == 0
517
518 # Write a new file and commit on feat
519 (two_branch_repo / "on_feat.py").write_text("x = 1\n")
520 _invoke(two_branch_repo, ["code", "add", "on_feat.py"])
521 _invoke(two_branch_repo, ["commit", "-m", "test: commit on feat"])
522
523 # main tip must be unchanged
524 tip_main_after = get_head_commit_id(two_branch_repo, "main")
525 assert tip_main_before == tip_main_after, (
526 "Commit after checkout feat must not advance main"
527 )
528
529 # feat tip must have advanced
530 tip_feat = get_head_commit_id(two_branch_repo, "feat")
531 assert tip_feat != tip_main_after, (
532 "Commit after checkout feat must advance feat, not main"
533 )
File History 1 commit