muse switch crashes with unhandled traceback from detached HEAD (git allows this cleanly)
muse switch <branch> crashes with an unhandled traceback from detached HEAD (git allows this; muse should too)
Confirmed gap
Git allows switching back to a branch from a detached HEAD state with
zero special ceremony — git switch <branch> and git checkout <branch> both just work, exactly like switching between two branches.
There is no "you must use a different command to leave detached HEAD"
rule in git.
muse switch <branch> does not honor this. From a detached HEAD, it
crashes with an unhandled Python traceback instead of switching (or
even failing cleanly):
➜ muse switch sha256:1621a550cdfa00c4df44c3bf7685b6bb02b0034f995c595d693d860fad1de00d
HEAD is now at sha256:1621a550cdfa00c4df44c3bf7685b6bb02b0034f995c595d693d860fad1de00d initial commit
➜ muse switch main
Traceback (most recent call last):
File "/Users/gabriel/.local/bin/muse", line 6, in <module>
sys.exit(main())
File ".../muse/cli/app.py", line 689, in main
args.func(args)
File ".../muse/cli/commands/switch.py", line 378, in run
current_branch = read_current_branch(root)
File ".../muse/core/refs.py", line 312, in read_current_branch
raise ValueError(
ValueError: Repository is in detached HEAD state. Run 'muse checkout <branch>' to return to a branch.
The only working recovery is muse checkout main — which happens to
work only because checkout.py reads HEAD state differently. This gap
was found live, mid-recording, not in a test.
Root cause (confirmed by reading the source, not guessed)
muse/cli/commands/switch.py:378:
current_branch = read_current_branch(root)
read_current_branch() (muse/core/refs.py:312) unconditionally
raises ValueError when HEAD is detached — it has no tolerant mode.
switch.py calls it completely unguarded, with no try/except, so the
raw exception propagates all the way to a Python traceback in the
user's terminal instead of any of switch.py's own structured
_emit_error() calls (which exist for every other error path in this
same function — mutual-exclusion flags, missing target, no previous
branch, etc.).
The fix pattern already exists in the same codebase and just wasn't
reused: muse/cli/commands/checkout.py (lines ~855-858) reads HEAD via
the tolerant read_head(root) helper instead:
_head_state = read_head(root)
current_branch: str | None = (
_head_state["branch"] if _head_state["kind"] == "branch" else None
)
This never raises — current_branch is simply None when detached,
and callers handle that case explicitly (see checkout.py's --merge
guard, which prints a clean, actionable error only when the specific
combination of flags actually requires an active branch).
Goal
muse switch <branch> must work from a detached HEAD exactly like git:
switching to an existing branch should just work, with no crash and no
special recovery command required. Any switch operation that
genuinely requires an active source branch (if any — needs auditing,
see Phase 1) should fail with a clean, structured _emit_error() call
naming exactly what's missing, never a raw traceback.
Multi-phase TDD roadmap
- Phase 1 — Reproduce + audit real dependents
- New test file
tests/test_cmd_switch_detached_head.py. First test: reproduce the exact crash above (detach, thenmuse switch <existing-branch>raisesValueError/SystemExitis not raised cleanly — pin down the current, broken behavior before touching any code). - Audit every use of
current_branchlater inswitch.py::run()after line 378 — determine which of them (if any) genuinely require an active branch (e.g.switch -recording,--merge's Cohen Transform path) versus which just want "the branch we're leaving, or None." This determines whether the fix is a straight swap toread_head()or needs per-call-site guards, matchingcheckout.py's pattern.
- New test file
- Phase 2 — Design decision
- Confirm with gabriel: should
switch.pyimport and reuseread_head()directly (matchingcheckout.py), or shouldread_current_branch()itself gain an optionalraise_on_detached: bool = Trueparameter so both commands share one call, defaulting True to avoid silently changing behavior for the many other call sites across the codebase that rely on it raising? Recommend the latter only if a grep shows few call sites; otherwise prefer matchingcheckout.pyexactly (reuseread_head(), don't touch the widely-used raising function).
- Confirm with gabriel: should
- Phase 3 — Implement
- Replace
switch.py:378's unguardedread_current_branch()call with the chosen tolerant pattern. - For any call site identified in Phase 1 that genuinely needs
an active branch, add an explicit, clean
_emit_error()call (matching every other error path already in this function) — never let a raw exception propagate. - Confirm
muse switch -(previous branch) from detached HEAD does something sensible — git'sgit switch -from detached HEAD returns to the branch you were on before detaching, not an error.
- Replace
- Phase 4 — Green the suite
- Phase 1's reproduction test now asserts the correct behavior (clean switch, exit 0, HEAD lands on the target branch) and passes.
- New tests: switch to an existing branch from detached HEAD
(success case); switch to a nonexistent branch from detached
HEAD (clean
USER_ERROR, not a crash);muse switch -c new-branchfrom detached HEAD (creates from the detached commit, matching git's behavior of branching off wherever HEAD currently points);--jsonmode emits a structured error envelope for every failure path touched by this fix, never raw stderr traceback text. - Full regression suite for
test_cmd_switch*.py,test_cmd_checkout*.py,test_core_refs.py— zero regressions, and confirm no other caller ofread_current_branch()was affected if Phase 2 chose to modify that shared function.
- Phase 5 — Real-world verification
- Real scratch repo: detach via
muse switch <commit-id>, thenmuse switch main— confirm clean switch, no traceback, matches the exact real-world repro from this ticket. - Confirm
muse checkout main(the existing working recovery path) still works identically afterward — this fix must not regress the one thing that currently does work.
- Real scratch repo: detach via
- Phase 6 — Ship
- Version bump, same pipeline as every other fix (build, publish to staging, smoke test, install, verify against the real CLI).
- Push
dev/maintolocal/staging/production.
Acceptance criteria
muse switch <existing-branch>from a detached HEAD state succeeds cleanly, matchinggit switch/git checkout's behavior exactly — no traceback, no special recovery command required.- Every error path in
switch.pythat touches HEAD state emits a structured, actionable error via the existing_emit_error()mechanism — never a raw unhandled exception. muse checkout <branch>'s existing detached-HEAD recovery behavior is unaffected.- Full regression suite passes with zero regressions.
Fixed and shipped to source (not yet in a published release — see note below).
Root cause confirmed exactly as filed:
switch.py:378's unguardedread_current_branch()call raisedValueErroron detached HEAD, propagating as a raw traceback instead of any ofswitch.py's own structured_emit_error()calls.Followed the ticket's own phased plan:
read_current_branch()call sites — 100+ across the codebase, all relying on it raising. Confirmed the ticket's own Phase 2 guidance applies: reuseread_head()(matchingcheckout.py's existing tolerant pattern) rather than widening the shared raising function.read_head()) per that audit result.run()path (line 378) and the-Cforce-create path (_run_force_create, same unguarded-read pattern, not explicitly named in the ticket's repro but sharing the identical root cause). Caught two latent crash-on-Nonebugs while wiring the fix through:_write_prev_branch(root, None)would have written the literal string"None"intoPREV_BRANCH;sanitize_display(None)would have raisedTypeError. Both guarded (_maybe_write_prev_branch,_display_branch).tests/test_cmd_switch_detached_head.py, 7 tests — exact repro,--jsonmode, switch to a nonexistent branch (clean structured error, not a crash),switch -c/switch -Cfrom detached (branches off the detached commit, matching git),switch -returning to the last real branch,muse checkout's existing recovery path unaffected. All 7 confirmed RED against the unfixed code, GREEN after. Fulltest_cmd_switch.py+test_cmd_checkout.py+test_core_refs.pyregression suite: 250 passed, zero regressions.muse switch mainfrom detached HEAD now exits 0 withSwitched to branch 'main', no traceback. Also manually verifiedswitch -c,switch -C, andswitch -from detached HEAD.Committed, merged to
dev/main, pushed tolocal/staging/production.Not done: Phase 6's version bump + publish. This fix is in source on all three remotes but not yet bundled into a published
muserelease (current published build is 0.2.1rc12). Let me know if you want it in its own release or batched with other pending fixes.Closing — acceptance criteria are all met; publishing is a separate release-management decision, not a blocker on the bug itself.