"""Phase 6 — Checkout interruption recovery. Invariant: if the process is killed between the working-tree update and the HEAD pointer write, muse status must detect the interrupted state and warn the user. Without this, the user sees target-branch files but HEAD says old branch — they might commit the wrong thing. Fix: _checkout_snapshot now accepts clear_marker=False so callers can keep CHECKOUT_HEAD alive through write_head_branch / write_head_commit. Timeline for a clean checkout: 1. write CHECKOUT_HEAD (target branch name) 2. mutate working tree (delete removed, restore added/modified) 3. call domain plugin.apply() 4. write HEAD (write_head_branch or write_head_commit) 5. unlink CHECKOUT_HEAD If killed between 2 and 4: CHECKOUT_HEAD exists, status shows interrupt. If killed between 4 and 5: CHECKOUT_HEAD exists, status still shows interrupt. (next muse checkout will clear it) Testing tiers ------------- Unit CHECKOUT_HEAD present after working-tree update, cleared after HEAD write Integration muse status reports checkout_interrupted=True when marker exists Data after a simulated crash (marker left), status correctly identifies target E2E successful checkout clears CHECKOUT_HEAD after write_head_branch """ from __future__ import annotations import json import pathlib from collections.abc import Mapping from unittest.mock import patch import pytest from tests.cli_test_helper import CliRunner from muse.core.paths import checkout_head_path runner = CliRunner() def _run(repo: pathlib.Path, *args: str, expect_failure: bool = False) -> "CliRunner.Result": r = runner.invoke(None, list(args), cwd=repo) if not expect_failure: assert r.exit_code == 0, f"muse {' '.join(args)} failed:\n{r.output}" return r def _checkout_head_path(repo: pathlib.Path) -> pathlib.Path: return checkout_head_path(repo) def _status(repo: pathlib.Path) -> Mapping[str, object]: r = runner.invoke(None, ["status", "--json"], cwd=repo) return json.loads(r.output) def _setup_two_branches(repo: pathlib.Path) -> None: """Create main with one file, branch-b with a different file.""" f = repo / "file.py" f.write_text("main content\n") _run(repo, "code", "add", "file.py") _run(repo, "commit", "-m", "main commit") _run(repo, "checkout", "-b", "branch-b") f.write_text("branch-b content\n") _run(repo, "code", "add", "file.py") _run(repo, "commit", "-m", "branch-b commit") _run(repo, "checkout", "main") # --------------------------------------------------------------------------- # Unit — CHECKOUT_HEAD lifecycle # --------------------------------------------------------------------------- class TestCheckoutHeadLifecycle: def test_checkout_head_absent_after_successful_checkout( self, muse_repo: pathlib.Path ) -> None: """CHECKOUT_HEAD must not exist after a successful branch switch.""" _setup_two_branches(muse_repo) _run(muse_repo, "checkout", "branch-b") assert not _checkout_head_path(muse_repo).exists(), ( "CHECKOUT_HEAD exists after a successful checkout — marker not cleared" ) def test_checkout_head_present_during_head_write( self, muse_repo: pathlib.Path ) -> None: """CHECKOUT_HEAD must still exist when write_head_branch is called. This is the key invariant: if the process is killed just before write_head_branch, the marker must be present so muse status detects it. """ _setup_two_branches(muse_repo) marker_state_during_write: list[bool] = [] real_write_head = __import__( "muse.core.store", fromlist=["write_head_branch"] ).write_head_branch def _spy_write_head(root: pathlib.Path, branch: str) -> None: marker_state_during_write.append(_checkout_head_path(root).exists()) return real_write_head(root, branch) with patch("muse.cli.commands.checkout.write_head_branch", side_effect=_spy_write_head): _run(muse_repo, "checkout", "branch-b") assert marker_state_during_write, "write_head_branch was never called" assert marker_state_during_write[0], ( "CHECKOUT_HEAD was already cleared before write_head_branch — " "interrupt in that window is undetectable" ) # --------------------------------------------------------------------------- # Integration — muse status detects planted CHECKOUT_HEAD # --------------------------------------------------------------------------- class TestStatusDetectsInterrupt: def test_status_shows_checkout_interrupted_when_marker_exists( self, muse_repo: pathlib.Path ) -> None: """If CHECKOUT_HEAD exists, status must report checkout_interrupted=True.""" _setup_two_branches(muse_repo) # Plant the marker (simulates crash during checkout to branch-b) _checkout_head_path(muse_repo).write_text("branch-b\n") status = _status(muse_repo) assert status["checkout_interrupted"] is True, ( "status did not detect CHECKOUT_HEAD — checkout_interrupted should be True" ) def test_status_shows_checkout_target(self, muse_repo: pathlib.Path) -> None: """checkout_target must match the branch name written in CHECKOUT_HEAD.""" _setup_two_branches(muse_repo) _checkout_head_path(muse_repo).write_text("branch-b\n") status = _status(muse_repo) assert status.get("checkout_target") == "branch-b", ( f"checkout_target should be 'branch-b', got {status.get('checkout_target')!r}" ) def test_status_clean_after_marker_removed(self, muse_repo: pathlib.Path) -> None: """After removing a planted CHECKOUT_HEAD, status shows no interrupt.""" _setup_two_branches(muse_repo) _checkout_head_path(muse_repo).write_text("branch-b\n") assert _status(muse_repo)["checkout_interrupted"] is True _checkout_head_path(muse_repo).unlink() assert _status(muse_repo)["checkout_interrupted"] is False # --------------------------------------------------------------------------- # Data — crash recovery: re-checkout fixes the state # --------------------------------------------------------------------------- class TestCrashRecovery: def test_re_checkout_after_interrupted_state_succeeds( self, muse_repo: pathlib.Path ) -> None: """Re-running muse checkout on a repo with CHECKOUT_HEAD clears it.""" _setup_two_branches(muse_repo) # Simulate partial checkout: working tree at branch-b, HEAD at main, # CHECKOUT_HEAD present (muse_repo / "file.py").write_text("branch-b content\n") _checkout_head_path(muse_repo).write_text("branch-b\n") # Re-checkout to branch-b — needs --force because tree is dirty _run(muse_repo, "checkout", "--force", "branch-b") assert not _checkout_head_path(muse_repo).exists(), ( "CHECKOUT_HEAD not cleared after re-checkout" ) assert _status(muse_repo)["checkout_interrupted"] is False