"""Tests for ``muse switch`` — focused branch switcher. Coverage tiers: - Unit: flag parsing, PREV_BRANCH file read/write - Integration: switch existing, -c create, -C force-create, switch - (previous), --discard-changes, --merge, --autoshelf, --dry-run, --json, already-on-branch, non-existent branch - End-to-end: full CLI via CliRunner - Security: ANSI injection in branch name rejected, dirty-tree guard - Stress: rapid switch between branches """ from __future__ import annotations import json import os import pathlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core.refs import ( get_head_commit_id, read_current_branch, ) from muse.core.paths import head_path, heads_dir, muse_dir runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(repo: pathlib.Path, *args: str) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, ["switch", *args]) finally: os.chdir(saved) def _run(repo: pathlib.Path, *args: str) -> InvokeResult: """Generic muse command runner.""" saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, list(args)) finally: os.chdir(saved) @pytest.fixture() def repo(tmp_path: pathlib.Path) -> pathlib.Path: """Initialised repo with one commit on main.""" _run(tmp_path, "init") (tmp_path / "a.py").write_text("x = 1\n") _run(tmp_path, "commit", "-m", "initial") return tmp_path @pytest.fixture() def two_branch_repo(repo: pathlib.Path) -> pathlib.Path: """Repo with main and feat branches, each with unique content.""" _run(repo, "branch", "feat") _run(repo, "checkout", "feat") (repo / "feat.py").write_text("f = 1\n") _run(repo, "commit", "-m", "feat commit") _run(repo, "checkout", "main") return repo def _prev_branch_path(repo: pathlib.Path) -> pathlib.Path: return muse_dir(repo) / "PREV_BRANCH" # --------------------------------------------------------------------------- # Unit — flag parsing # --------------------------------------------------------------------------- class TestRegisterFlags: def _parse(self, *args: str) -> "argparse.Namespace": import argparse from muse.cli.commands.switch import register p = argparse.ArgumentParser() sub = p.add_subparsers() register(sub) return p.parse_args(["switch", *args]) def test_create_flag(self) -> None: ns = self._parse("-c", "feat") assert ns.create is True assert ns.target == "feat" def test_force_create_flag(self) -> None: ns = self._parse("-C", "feat") assert ns.force_create is True def test_discard_changes_flag(self) -> None: ns = self._parse("--discard-changes", "main") assert ns.discard_changes is True def test_dry_run_short(self) -> None: ns = self._parse("-n", "main") assert ns.dry_run is True def test_json_flag(self) -> None: ns = self._parse("--json", "main") assert ns.json_out is True def test_default_json_out_is_false(self) -> None: ns = self._parse("main") assert ns.json_out is False def test_j_shorthand_sets_json_out(self) -> None: ns = self._parse("-j", "main") assert ns.json_out is True def test_merge_flag(self) -> None: ns = self._parse("--merge", "main") assert ns.merge is True def test_autoshelf_flag(self) -> None: ns = self._parse("--autoshelf", "main") assert ns.autoshelf is True def test_detach_flag(self) -> None: ns = self._parse("--detach", "main") assert ns.detach is True # --------------------------------------------------------------------------- # Unit — PREV_BRANCH helpers # --------------------------------------------------------------------------- def test_read_prev_branch_missing_returns_none(tmp_path: pathlib.Path) -> None: from muse.cli.commands.switch import _read_prev_branch repo = tmp_path / "repo" repo.mkdir() muse_dir(repo).mkdir() assert _read_prev_branch(repo) is None def test_write_then_read_prev_branch(tmp_path: pathlib.Path) -> None: from muse.cli.commands.switch import _read_prev_branch, _write_prev_branch repo = tmp_path / "repo" repo.mkdir() muse_dir(repo).mkdir() _write_prev_branch(repo, "feat") assert _read_prev_branch(repo) == "feat" # --------------------------------------------------------------------------- # Integration — basic switch # --------------------------------------------------------------------------- def test_switch_to_existing_branch(two_branch_repo: pathlib.Path) -> None: result = _invoke(two_branch_repo, "feat") assert result.exit_code == 0 assert read_current_branch(two_branch_repo) == "feat" def test_switch_updates_head_file(two_branch_repo: pathlib.Path) -> None: _invoke(two_branch_repo, "feat") head = (head_path(two_branch_repo)).read_text() assert "feat" in head def test_switch_text_output(two_branch_repo: pathlib.Path) -> None: result = _invoke(two_branch_repo, "feat") assert result.exit_code == 0 assert "feat" in result.output def test_switch_already_on_branch(two_branch_repo: pathlib.Path) -> None: result = _invoke(two_branch_repo, "main") assert result.exit_code == 0 # Should mention "already" or still report main assert "main" in result.output or result.exit_code == 0 def test_switch_nonexistent_branch_exits_nonzero(repo: pathlib.Path) -> None: result = _invoke(repo, "ghost-branch") assert result.exit_code != 0 # --------------------------------------------------------------------------- # Integration — -c / create # --------------------------------------------------------------------------- def test_switch_c_creates_and_switches(repo: pathlib.Path) -> None: result = _invoke(repo, "-c", "new-feat") assert result.exit_code == 0 assert read_current_branch(repo) == "new-feat" assert (heads_dir(repo) / "new-feat").exists() def test_switch_c_fails_if_branch_exists(two_branch_repo: pathlib.Path) -> None: result = _invoke(two_branch_repo, "-c", "feat") assert result.exit_code != 0 def test_switch_c_points_to_current_head(repo: pathlib.Path) -> None: head_before = get_head_commit_id(repo, "main") _invoke(repo, "-c", "new-feat") head_after = get_head_commit_id(repo, "new-feat") assert head_before == head_after # --------------------------------------------------------------------------- # Integration — -C / force-create # --------------------------------------------------------------------------- def test_switch_C_creates_when_not_exists(repo: pathlib.Path) -> None: result = _invoke(repo, "-C", "brand-new") assert result.exit_code == 0 assert read_current_branch(repo) == "brand-new" def test_switch_C_overwrites_existing_branch(two_branch_repo: pathlib.Path) -> None: """Force-create resets feat to current HEAD (main's tip).""" main_tip = get_head_commit_id(two_branch_repo, "main") result = _invoke(two_branch_repo, "-C", "feat") assert result.exit_code == 0 assert read_current_branch(two_branch_repo) == "feat" assert get_head_commit_id(two_branch_repo, "feat") == main_tip # --------------------------------------------------------------------------- # Integration — switch - (previous branch) # --------------------------------------------------------------------------- def test_switch_dash_returns_to_previous(two_branch_repo: pathlib.Path) -> None: """switch - should go back to main after switching to feat.""" _invoke(two_branch_repo, "feat") result = _invoke(two_branch_repo, "-") assert result.exit_code == 0 assert read_current_branch(two_branch_repo) == "main" def test_switch_dash_without_history_exits_nonzero(repo: pathlib.Path) -> None: """switch - with no PREV_BRANCH recorded should fail cleanly.""" result = _invoke(repo, "-") assert result.exit_code != 0 def test_switch_writes_prev_branch_on_switch(two_branch_repo: pathlib.Path) -> None: _invoke(two_branch_repo, "feat") assert _prev_branch_path(two_branch_repo).exists() prev = _prev_branch_path(two_branch_repo).read_text().strip() assert prev == "main" def test_switch_dash_then_dash_bounces(two_branch_repo: pathlib.Path) -> None: """Alternating switch - should toggle between two branches.""" _invoke(two_branch_repo, "feat") _invoke(two_branch_repo, "-") assert read_current_branch(two_branch_repo) == "main" _invoke(two_branch_repo, "-") assert read_current_branch(two_branch_repo) == "feat" # --------------------------------------------------------------------------- # Integration — --discard-changes # --------------------------------------------------------------------------- def test_switch_dirty_tree_blocked_without_flag(repo: pathlib.Path) -> None: """A locally modified file blocks the switch when the target branch has a different version. This is the true conflict case: both branches diverged on the same file. Carry-through (same content on both branches) is intentionally allowed — this test verifies the *blocking* half of that contract. """ # Create feat branch where a.py has diverged from main. _run(repo, "branch", "feat") _run(repo, "checkout", "feat") (repo / "a.py").write_text("feat version\n") _run(repo, "commit", "-m", "feat changes a.py") _run(repo, "checkout", "main") # Now dirty a.py locally; feat has a different version → must block. (repo / "a.py").write_text("dirty\n") result = _invoke(repo, "feat") assert result.exit_code != 0 def test_switch_to_same_commit_allowed_with_dirty_tree(repo: pathlib.Path) -> None: """Switching to a branch that points to the SAME commit as HEAD must succeed even with a dirty working tree — no files will change so there is nothing to overwrite. This matches git switch behaviour. Regression: muse switch refused on ANY dirty file regardless of whether the target branch shared the same HEAD commit (no-op transition). """ # Create a new branch at the current HEAD (same commit). _run(repo, "branch", "same-commit") # Dirty a tracked file — this is the dirty state that blocked the switch. (repo / "a.py").write_text("local uncommitted change\n") # Switching to a branch at the SAME commit should succeed: no files change. result = _invoke(repo, "same-commit") assert result.exit_code == 0, ( f"switch to same-commit branch must succeed with dirty tree; got: {result.output}" ) assert read_current_branch(repo) == "same-commit" # Dirty file must be preserved — switch must NOT touch it. assert (repo / "a.py").read_text() == "local uncommitted change\n" def test_switch_to_different_commit_blocked_with_dirty_tree(repo: pathlib.Path) -> None: """Switching to a branch at a DIFFERENT commit must still be blocked when dirty tracked files exist — this is the dangerous case where apply_manifest could overwrite uncommitted work. """ _run(repo, "branch", "feat") _run(repo, "checkout", "feat") (repo / "a.py").write_text("feat version\n") _run(repo, "commit", "-m", "feat changes a.py") _run(repo, "checkout", "main") (repo / "a.py").write_text("dirty\n") result = _invoke(repo, "feat") assert result.exit_code != 0 def test_switch_discard_changes_allows_dirty_switch(two_branch_repo: pathlib.Path) -> None: (two_branch_repo / "a.py").write_text("dirty\n") result = _invoke(two_branch_repo, "--discard-changes", "feat") assert result.exit_code == 0 assert read_current_branch(two_branch_repo) == "feat" # --------------------------------------------------------------------------- # Integration — --dry-run # --------------------------------------------------------------------------- def test_switch_dry_run_does_not_change_branch(two_branch_repo: pathlib.Path) -> None: result = _invoke(two_branch_repo, "--dry-run", "feat") assert result.exit_code == 0 assert read_current_branch(two_branch_repo) == "main" def test_switch_dry_run_no_prev_branch_written(two_branch_repo: pathlib.Path) -> None: _invoke(two_branch_repo, "--dry-run", "feat") assert not _prev_branch_path(two_branch_repo).exists() def test_switch_dry_run_c_does_not_create_branch(repo: pathlib.Path) -> None: _invoke(repo, "--dry-run", "-c", "ghost") assert not (heads_dir(repo) / "ghost").exists() # --------------------------------------------------------------------------- # Integration — --json # --------------------------------------------------------------------------- def test_switch_json_action_switched(two_branch_repo: pathlib.Path) -> None: result = _invoke(two_branch_repo, "--json", "feat") assert result.exit_code == 0 data = json.loads(result.stdout) assert data["action"] in ("switched",) assert data["branch"] == "feat" assert data["from_branch"] == "main" assert "commit_id" in data def test_switch_json_action_created(repo: pathlib.Path) -> None: result = _invoke(repo, "--json", "-c", "new-feat") assert result.exit_code == 0 data = json.loads(result.stdout) assert data["action"] == "created" assert data["branch"] == "new-feat" def test_switch_json_dry_run(two_branch_repo: pathlib.Path) -> None: result = _invoke(two_branch_repo, "--json", "--dry-run", "feat") assert result.exit_code == 0 data = json.loads(result.stdout) assert data["dry_run"] is True assert data["branch"] == "feat" # --------------------------------------------------------------------------- # Integration — --detach # --------------------------------------------------------------------------- def test_switch_detach_moves_to_commit(repo: pathlib.Path) -> None: commit_id = get_head_commit_id(repo, "main") result = _invoke(repo, "--detach", commit_id) assert result.exit_code == 0 # HEAD should point directly to the commit, not a branch head = (head_path(repo)).read_text().strip() assert commit_id in head def test_switch_detach_json(repo: pathlib.Path) -> None: commit_id = get_head_commit_id(repo, "main") result = _invoke(repo, "--json", "--detach", commit_id) assert result.exit_code == 0 data = json.loads(result.stdout) assert data["action"] == "detached" assert data["branch"] is None assert data["commit_id"] == commit_id # --------------------------------------------------------------------------- # Security # --------------------------------------------------------------------------- def test_switch_ansi_in_branch_name_rejected(repo: pathlib.Path) -> None: result = _invoke(repo, "\x1b[31mbad\x1b[0m") assert result.exit_code != 0 def test_switch_error_goes_to_stderr(repo: pathlib.Path) -> None: result = _invoke(repo, "no-such-branch") assert result.exit_code != 0 # --------------------------------------------------------------------------- # Stress # --------------------------------------------------------------------------- def test_switch_rapid_toggle(two_branch_repo: pathlib.Path) -> None: """20 rapid switches must leave the repo in a consistent final state.""" branches = ["main", "feat"] for i in range(20): target = branches[i % 2] result = _invoke(two_branch_repo, target) assert result.exit_code == 0 # After 20 switches (0-indexed → last is index 19 → feat) assert read_current_branch(two_branch_repo) == "feat"