gabriel / muse public
test_cmd_switch_detached_head.py python
139 lines 5.6 KB
Raw
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