test_cmd_switch_detached_head.py
python
sha256:dfb030d712110599faeb978c6b5eaa4534b9544477860a1e44542d41fdb37abd
fix: muse switch crashes with unhandled traceback from deta…
Sonnet 5
patch
8 hours ago
| 1 | """muse#106 — ``muse switch`` must work from a detached HEAD, like git. |
| 2 | |
| 3 | git allows switching back to a branch (or creating one, or using ``-``) from |
| 4 | detached HEAD with zero special ceremony. ``muse switch`` did not: it called |
| 5 | the strict ``read_current_branch()`` unguarded, which raises ``ValueError`` |
| 6 | on detached HEAD, producing a raw Python traceback instead of a clean switch |
| 7 | or a structured error. |
| 8 | |
| 9 | Coverage: reproduce the crash (RED before the fix), then every case the |
| 10 | ticket's acceptance criteria call out: switch to an existing branch, switch |
| 11 | to a nonexistent branch, ``-c`` from detached, ``-C`` from detached, and |
| 12 | ``--json`` mode never emitting a raw traceback. |
| 13 | """ |
| 14 | from __future__ import annotations |
| 15 | |
| 16 | import json |
| 17 | import os |
| 18 | import pathlib |
| 19 | |
| 20 | import pytest |
| 21 | |
| 22 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 23 | from muse.core.refs import get_head_commit_id, read_head |
| 24 | |
| 25 | runner = CliRunner() |
| 26 | |
| 27 | |
| 28 | def _run(repo: pathlib.Path, *args: str) -> InvokeResult: |
| 29 | saved = os.getcwd() |
| 30 | try: |
| 31 | os.chdir(repo) |
| 32 | return runner.invoke(None, list(args)) |
| 33 | finally: |
| 34 | os.chdir(saved) |
| 35 | |
| 36 | |
| 37 | @pytest.fixture() |
| 38 | def detached_repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 39 | """Repo with two commits on main, HEAD detached at the first commit.""" |
| 40 | _run(tmp_path, "init") |
| 41 | (tmp_path / "a.py").write_text("x = 1\n") |
| 42 | _run(tmp_path, "code", "add", "a.py") |
| 43 | _run(tmp_path, "commit", "-m", "first") |
| 44 | first = get_head_commit_id(tmp_path, "main") |
| 45 | (tmp_path / "a.py").write_text("x = 2\n") |
| 46 | _run(tmp_path, "code", "add", "a.py") |
| 47 | _run(tmp_path, "commit", "-m", "second") |
| 48 | result = _run(tmp_path, "switch", str(first), "--detach") |
| 49 | assert result.exit_code == 0, result.output |
| 50 | head = read_head(tmp_path) |
| 51 | assert head["kind"] == "commit", "fixture must start detached" |
| 52 | return tmp_path |
| 53 | |
| 54 | |
| 55 | def test_switch_to_existing_branch_from_detached_head_succeeds(detached_repo: pathlib.Path) -> None: |
| 56 | """The exact repro from the ticket: must switch cleanly, not crash.""" |
| 57 | result = _run(detached_repo, "switch", "main") |
| 58 | assert result.exit_code == 0, result.output |
| 59 | assert "Traceback" not in result.output |
| 60 | assert "Traceback" not in result.stderr |
| 61 | head = read_head(detached_repo) |
| 62 | assert head["kind"] == "branch" |
| 63 | assert head["branch"] == "main" |
| 64 | |
| 65 | |
| 66 | def test_switch_to_existing_branch_from_detached_head_json(detached_repo: pathlib.Path) -> None: |
| 67 | result = _run(detached_repo, "switch", "main", "--json") |
| 68 | assert result.exit_code == 0, result.output |
| 69 | assert "Traceback" not in result.output |
| 70 | assert "Traceback" not in result.stderr |
| 71 | payload = json.loads(result.output) |
| 72 | assert payload["action"] == "switched" |
| 73 | assert payload["branch"] == "main" |
| 74 | assert payload["from_branch"] is None |
| 75 | |
| 76 | |
| 77 | def test_switch_to_nonexistent_branch_from_detached_head_clean_error(detached_repo: pathlib.Path) -> None: |
| 78 | """Must fail cleanly (structured error), never a raw traceback.""" |
| 79 | result = _run(detached_repo, "switch", "does-not-exist", "--json") |
| 80 | assert "Traceback" not in result.output |
| 81 | assert "Traceback" not in result.stderr |
| 82 | assert result.exit_code != 0 |
| 83 | payload = json.loads(result.output) |
| 84 | assert "error" in payload |
| 85 | |
| 86 | |
| 87 | def test_switch_create_from_detached_head_branches_off_current_commit(detached_repo: pathlib.Path) -> None: |
| 88 | """``switch -c`` from detached HEAD creates the branch at the detached |
| 89 | commit, matching git's behavior of branching off wherever HEAD points.""" |
| 90 | head = read_head(detached_repo) |
| 91 | detached_commit = head["commit_id"] |
| 92 | result = _run(detached_repo, "switch", "-c", "new-branch") |
| 93 | assert result.exit_code == 0, result.output |
| 94 | assert "Traceback" not in result.output |
| 95 | assert "Traceback" not in result.stderr |
| 96 | new_head = read_head(detached_repo) |
| 97 | assert new_head["kind"] == "branch" |
| 98 | assert new_head["branch"] == "new-branch" |
| 99 | assert get_head_commit_id(detached_repo, "new-branch") == detached_commit |
| 100 | |
| 101 | |
| 102 | def test_switch_force_create_from_detached_head(detached_repo: pathlib.Path) -> None: |
| 103 | """``switch -C`` from detached HEAD must not crash either (same root |
| 104 | cause, same code pattern, not explicitly called out in the ticket's |
| 105 | acceptance list but sharing the exact same unguarded read).""" |
| 106 | result = _run(detached_repo, "switch", "-C", "force-branch") |
| 107 | assert result.exit_code == 0, result.output |
| 108 | assert "Traceback" not in result.output |
| 109 | assert "Traceback" not in result.stderr |
| 110 | |
| 111 | |
| 112 | def test_switch_dash_from_detached_head_returns_to_real_previous_branch(detached_repo: pathlib.Path) -> None: |
| 113 | """``switch -`` must not write the literal string "None" as a branch |
| 114 | name into PREV_BRANCH while detached -- it should return to the last |
| 115 | real branch the user was on before detaching.""" |
| 116 | result = _run(detached_repo, "switch", "main") |
| 117 | assert result.exit_code == 0, result.output |
| 118 | |
| 119 | first = get_head_commit_id(detached_repo, "main") |
| 120 | detach = _run(detached_repo, "switch", first, "--detach") |
| 121 | assert detach.exit_code == 0, detach.output |
| 122 | |
| 123 | result = _run(detached_repo, "switch", "-") |
| 124 | assert result.exit_code == 0, result.output |
| 125 | assert "Traceback" not in result.output |
| 126 | assert "Traceback" not in result.stderr |
| 127 | head = read_head(detached_repo) |
| 128 | assert head["kind"] == "branch" |
| 129 | assert head["branch"] == "main" |
| 130 | |
| 131 | |
| 132 | def test_checkout_recovery_path_still_works(detached_repo: pathlib.Path) -> None: |
| 133 | """The existing working recovery (muse checkout <branch>) must be |
| 134 | unaffected by this fix.""" |
| 135 | result = _run(detached_repo, "checkout", "main") |
| 136 | assert result.exit_code == 0, result.output |
| 137 | head = read_head(detached_repo) |
| 138 | assert head["kind"] == "branch" |
| 139 | assert head["branch"] == "main" |
File History
1 commit
sha256:dfb030d712110599faeb978c6b5eaa4534b9544477860a1e44542d41fdb37abd
fix: muse switch crashes with unhandled traceback from deta…
Sonnet 5
patch
8 hours ago