"""Tests for ``muse checkout --autostash``. Coverage matrix --------------- Unit - ``_stash_push_programmatic`` / ``_stash_pop_programmatic`` in isolation - ``_apply_autostash`` text vs JSON mode output Integration (full CLI round-trips via CliRunner) - clean tree: --autostash is a no-op (no stash entry created) - dirty tree: stash pushed, branch switched, stash popped - dirty tree, --json mode: correct JSON on stdout; autostash lines on stderr - stash pop restores modified file content on the new branch - stash pop restores newly-added files that don't exist in target branch - ``-b`` + ``--autostash``: new-branch creation ignores autostash (no push) - ``--autostash`` + ``--force`` is rejected - ``--autostash`` + ``--merge`` is rejected - ``--autostash`` + ``--dry-run``: no stash created; exit 0 if branch exists - already-on same branch: autostash no-op, no stash entry - guard.py error message includes all four remedies when dirty without flags - pop failure (corrupt stash): warns to stderr, does NOT re-raise The suite uses only stdlib + pytest; no mocking of internals. Every test operates against a real, hermetic Muse repo in tmp_path. """ from __future__ import annotations import io import json import os import pathlib import sys import contextlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core.store import read_current_branch from muse.cli.commands.stash import ( _stash_push_programmatic, _stash_pop_programmatic, _load_stash, ) from muse.cli.commands.checkout import _apply_autostash runner = CliRunner() # ── Helpers ─────────────────────────────────────────────────────────────────── def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) def _commit(repo: pathlib.Path, files: dict[str, str], msg: str = "commit") -> None: for name, content in files.items(): (repo / name).write_text(content, encoding="utf-8") _invoke(repo, ["commit", "-m", msg]) def _stash_size(repo: pathlib.Path) -> int: return len(_load_stash(repo)) # ── Fixtures ────────────────────────────────────────────────────────────────── @pytest.fixture() def two_branch_repo(tmp_path: pathlib.Path) -> pathlib.Path: """Repo with ``main`` and ``feat`` branches with distinct committed files. main: a.py="main\\n" feat: a.py="main\\n", b.py="feat\\n" """ saved = os.getcwd() try: os.chdir(tmp_path) runner.invoke(None, ["init"]) finally: os.chdir(saved) _commit(tmp_path, {"a.py": "main\n"}, "main: initial") _invoke(tmp_path, ["checkout", "-b", "feat"]) _commit(tmp_path, {"b.py": "feat\n"}, "feat: add b.py") _invoke(tmp_path, ["checkout", "main"]) return tmp_path # ── Unit: _stash_push_programmatic / _stash_pop_programmatic ───────────────── class TestProgrammaticStashAPI: """``_stash_push_programmatic`` and ``_stash_pop_programmatic`` are the building blocks for autostash — they must work correctly in isolation.""" def test_push_returns_none_when_clean(self, two_branch_repo: pathlib.Path) -> None: """Nothing to stash → None returned, stash stack unchanged.""" result = _stash_push_programmatic(two_branch_repo) assert result is None assert _stash_size(two_branch_repo) == 0 def test_push_returns_entry_when_dirty(self, two_branch_repo: pathlib.Path) -> None: """Modified file → entry returned with correct metadata.""" (two_branch_repo / "a.py").write_text("modified\n", encoding="utf-8") entry = _stash_push_programmatic(two_branch_repo, message="wip") assert entry is not None assert entry["message"] == "wip" assert entry["branch"] == "main" assert "a.py" in entry["delta"] assert _stash_size(two_branch_repo) == 1 def test_push_restores_working_tree_to_head(self, two_branch_repo: pathlib.Path) -> None: """After push, working tree matches HEAD (file reverted to committed state).""" (two_branch_repo / "a.py").write_text("modified\n", encoding="utf-8") _stash_push_programmatic(two_branch_repo) assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "main\n" def test_push_captures_added_files(self, two_branch_repo: pathlib.Path) -> None: """Newly-added files (not in HEAD) are captured in the stash delta.""" (two_branch_repo / "new.py").write_text("new\n", encoding="utf-8") # new.py is untracked — push captures it only if the plugin sees it. # In muse, snapshot includes all non-ignored files, so it IS in delta. entry = _stash_push_programmatic(two_branch_repo) if entry is not None: # If captured, it must be in delta assert "new.py" in entry["delta"] def test_pop_raises_on_empty_stash(self, two_branch_repo: pathlib.Path) -> None: with pytest.raises(ValueError, match="No stash entries"): _stash_pop_programmatic(two_branch_repo) def test_pop_raises_on_out_of_range_index(self, two_branch_repo: pathlib.Path) -> None: (two_branch_repo / "a.py").write_text("v2\n", encoding="utf-8") _stash_push_programmatic(two_branch_repo) with pytest.raises(ValueError, match="out of range"): _stash_pop_programmatic(two_branch_repo, index=5) def test_pop_restores_file_content(self, two_branch_repo: pathlib.Path) -> None: """Pop restores the exact bytes that were stashed.""" (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8") _stash_push_programmatic(two_branch_repo) assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "main\n" _stash_pop_programmatic(two_branch_repo) assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "dirty\n" def test_pop_removes_entry_from_stack(self, two_branch_repo: pathlib.Path) -> None: (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8") _stash_push_programmatic(two_branch_repo) assert _stash_size(two_branch_repo) == 1 _stash_pop_programmatic(two_branch_repo) assert _stash_size(two_branch_repo) == 0 def test_push_pop_roundtrip_message_preserved(self, two_branch_repo: pathlib.Path) -> None: (two_branch_repo / "a.py").write_text("v3\n", encoding="utf-8") _stash_push_programmatic(two_branch_repo, message="WIP on main: autostash") entry = _stash_pop_programmatic(two_branch_repo) assert entry["message"] == "WIP on main: autostash" # ── Unit: _apply_autostash output ──────────────────────────────────────────── class TestApplyAutostash: """``_apply_autostash`` must emit the right text and use the right streams.""" def test_text_mode_prints_to_stdout(self, two_branch_repo: pathlib.Path) -> None: (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8") _stash_push_programmatic(two_branch_repo) out = io.StringIO() with contextlib.redirect_stdout(out): _apply_autostash(two_branch_repo, "text") assert "Applied autostash" in out.getvalue() def test_json_mode_prints_to_stderr(self, two_branch_repo: pathlib.Path) -> None: (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8") _stash_push_programmatic(two_branch_repo) out = io.StringIO() err = io.StringIO() with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): _apply_autostash(two_branch_repo, "json") assert out.getvalue() == "" assert "autostash" in err.getvalue() def test_corrupt_stash_warns_stderr_does_not_raise(self, two_branch_repo: pathlib.Path) -> None: """Missing objects → warning on stderr, no exception propagation.""" (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8") entry = _stash_push_programmatic(two_branch_repo) assert entry is not None # Corrupt the object store by zeroing the object file for a.py obj_id = entry["delta"]["a.py"] obj_path = two_branch_repo / ".muse" / "objects" / obj_id[:2] / obj_id[2:] if obj_path.exists(): obj_path.chmod(0o644) obj_path.write_bytes(b"") # zero it out → read_object returns None err = io.StringIO() with contextlib.redirect_stderr(err): _apply_autostash(two_branch_repo, "text") # must not raise assert "stash@{0}" in err.getvalue() or "autostash" in err.getvalue() # ── Integration: --autostash CLI end-to-end ─────────────────────────────────── class TestAutostashCLI: """Full CLI round-trips through CliRunner.""" # ── clean tree ──────────────────────────────────────────────────────────── def test_clean_tree_no_stash_entry_created(self, two_branch_repo: pathlib.Path) -> None: """--autostash on a clean tree → switch succeeds, stash stack stays empty.""" result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) assert result.exit_code == 0 assert read_current_branch(two_branch_repo) == "feat" assert _stash_size(two_branch_repo) == 0 def test_clean_tree_no_autostash_message(self, two_branch_repo: pathlib.Path) -> None: """No stash push message printed when tree is already clean.""" result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) assert "Stashing" not in result.output assert "Applied autostash" not in result.output # ── dirty tree ──────────────────────────────────────────────────────────── def test_dirty_tree_switch_succeeds(self, two_branch_repo: pathlib.Path) -> None: """Dirty tracked file does not block checkout when --autostash is used.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) assert result.exit_code == 0, result.stderr assert read_current_branch(two_branch_repo) == "feat" def test_dirty_tree_stash_popped_after_switch(self, two_branch_repo: pathlib.Path) -> None: """After the switch, stash stack is empty (pop happened).""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) assert _stash_size(two_branch_repo) == 0 def test_dirty_tree_changes_restored_on_new_branch(self, two_branch_repo: pathlib.Path) -> None: """Stashed changes are visible in the working tree after arriving on feat.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n" def test_text_mode_prints_stash_then_applied(self, two_branch_repo: pathlib.Path) -> None: """Text mode: 'Stashing…' before switch, 'Applied autostash.' after.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) assert "Stashing" in result.output assert "Applied autostash" in result.output # ── JSON mode ───────────────────────────────────────────────────────────── def test_json_mode_stdout_is_valid_json(self, two_branch_repo: pathlib.Path) -> None: """In --json mode, stdout must be parseable JSON (no autostash noise).""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") result = _invoke(two_branch_repo, ["checkout", "--autostash", "--json", "feat"]) assert result.exit_code == 0, result.stderr # stdout has two lines: checkout JSON + possibly "Stashing..." but # Stashing goes to stdout in text mode. In JSON mode it must be # parseable — find the JSON line. lines = [l for l in result.output.strip().splitlines() if l.strip().startswith("{")] assert len(lines) >= 1 payload = json.loads(lines[0]) assert payload["action"] == "switched" assert payload["branch"] == "feat" def test_json_mode_applied_autostash_goes_to_stderr(self, two_branch_repo: pathlib.Path) -> None: """'Applied autostash' must not appear on stdout in JSON mode.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") result = _invoke(two_branch_repo, ["checkout", "--autostash", "--json", "feat"]) assert "Applied autostash" not in result.output # ── new file survives switch ────────────────────────────────────────────── def test_new_file_on_main_survives_to_feat(self, two_branch_repo: pathlib.Path) -> None: """A file added only on main (not committed) is stashed and popped onto feat.""" (two_branch_repo / "wip.py").write_text("todo\n", encoding="utf-8") result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) assert result.exit_code == 0, result.stderr # wip.py should be restored on feat (it was in the stash delta) assert (two_branch_repo / "wip.py").read_text(encoding="utf-8") == "todo\n" # ── mutually exclusive flags ────────────────────────────────────────────── def test_autostash_and_force_rejected(self, two_branch_repo: pathlib.Path) -> None: result = _invoke(two_branch_repo, ["checkout", "--autostash", "--force", "feat"]) assert result.exit_code != 0 assert "mutually exclusive" in (result.stderr or result.output).lower() def test_autostash_and_merge_rejected(self, two_branch_repo: pathlib.Path) -> None: result = _invoke(two_branch_repo, ["checkout", "--autostash", "--merge", "feat"]) assert result.exit_code != 0 assert "mutually exclusive" in (result.stderr or result.output).lower() # ── --dry-run + --autostash ──────────────────────────────────────────────── def test_dry_run_no_stash_created(self, two_branch_repo: pathlib.Path) -> None: """--dry-run never actually stashes — it just previews.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"]) assert _stash_size(two_branch_repo) == 0 def test_dry_run_no_branch_switch(self, two_branch_repo: pathlib.Path) -> None: """--dry-run + --autostash must not change the current branch.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"]) assert read_current_branch(two_branch_repo) == "main" def test_dry_run_working_tree_unchanged(self, two_branch_repo: pathlib.Path) -> None: """--dry-run must not touch the working tree at all.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"]) assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n" def test_dry_run_exits_zero_on_existing_branch(self, two_branch_repo: pathlib.Path) -> None: result = _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"]) assert result.exit_code == 0 # ── -b + --autostash ────────────────────────────────────────────────────── def test_create_branch_autostash_is_noop(self, two_branch_repo: pathlib.Path) -> None: """``-b`` stays at HEAD — no snapshot switch, so no stash needed.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") result = _invoke(two_branch_repo, ["checkout", "--autostash", "-b", "experiment"]) assert result.exit_code == 0, result.stderr assert read_current_branch(two_branch_repo) == "experiment" # Changes preserved (no stash push on -b) assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n" assert _stash_size(two_branch_repo) == 0 # ── already on target branch ────────────────────────────────────────────── def test_already_on_branch_no_stash(self, two_branch_repo: pathlib.Path) -> None: """Checking out the current branch is a no-op; autostash not triggered.""" result = _invoke(two_branch_repo, ["checkout", "--autostash", "main"]) assert result.exit_code == 0 assert _stash_size(two_branch_repo) == 0 # ── error message (no --autostash) ─────────────────────────────────────── def test_dirty_without_autostash_shows_all_remedies(self, two_branch_repo: pathlib.Path) -> None: """guard.py error lists muse stash, --merge, --force, and --autostash.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") result = _invoke(two_branch_repo, ["checkout", "feat"]) assert result.exit_code != 0 err = result.stderr or "" assert "muse stash" in err assert "--merge" in err assert "--force" in err assert "--autostash" in err # ── stash message format ────────────────────────────────────────────────── def test_autostash_message_matches_git_idiom(self, two_branch_repo: pathlib.Path) -> None: """Stash message follows git's 'WIP on : autostash' format.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") # Intercept before pop by pushing manually and inspecting entry = _stash_push_programmatic( two_branch_repo, message=f"WIP on main: autostash", ) assert entry is not None assert entry["message"].startswith("WIP on main") # Clean up _stash_pop_programmatic(two_branch_repo) # ── round-trip fidelity ─────────────────────────────────────────────────── def test_multiple_dirty_files_all_restored(self, two_branch_repo: pathlib.Path) -> None: """All modified files are stashed and fully restored after the switch.""" (two_branch_repo / "a.py").write_text("a-wip\n", encoding="utf-8") (two_branch_repo / "c.py").write_text("new-file\n", encoding="utf-8") _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "a-wip\n" assert (two_branch_repo / "c.py").read_text(encoding="utf-8") == "new-file\n" assert _stash_size(two_branch_repo) == 0 def test_switch_back_restores_original_state(self, two_branch_repo: pathlib.Path) -> None: """Round-trip main→feat→main with autostash; a.py ends at the wip value.""" (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) assert read_current_branch(two_branch_repo) == "feat" # Switch back _invoke(two_branch_repo, ["checkout", "--autostash", "main"]) assert read_current_branch(two_branch_repo) == "main" # wip was re-stashed on feat and re-popped on main assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n"