gabriel / muse public
fix patch fix/106-switch-detached-head #1 / 1
AI Agent gabriel · 3 hours ago · Oct 21, 2026 · Diff

fix: muse switch crashes with unhandled traceback from detached HEAD

muse#106. git allows switching away from detached HEAD with zero special ceremony -- git switch <branch> and git checkout <branch> both just work. muse switch did not: it called the strict read_current_branch() unguarded at the top of run(), which raises ValueError on detached HEAD. The raw exception propagated all the way to an unhandled Python traceback in the user's terminal, instead of any of switch.py's own structured _emit_error calls that exist for every other error path in this function. The only working recovery was muse checkout <branch>, which happens to work only because checkout.py reads HEAD via the tolerant read_head() helper instead.

Root cause confirmed by reading the source (muse/core/refs.py:312) and reproduced live before any code change: muse switch <commit> --detach, then muse switch main, crashes every time.

Fix: switch.py now reads HEAD via the same tolerant read_head() pattern checkout.py already uses, in both the main run() path and the -C force-create path (_run_force_create) -- same unguarded read, same crash, not explicitly called out in the ticket's repro but sharing the identical root cause. current_branch becomes None when detached; every downstream use already only wanted "the branch we're leaving, or None" (display, PREV_BRANCH bookkeeping, same-commit fast-path comparison) -- none of them genuinely required an active branch. Did not touch read_current_branch() itself: it has 100+ call sites across the codebase relying on it raising, exactly the ticket's own Phase 2 guidance for when to prefer matching checkout.py's pattern over widening the shared raising function.

Two latent crash-on-None bugs caught while fixing the primary one, both on code paths only reachable once current_branch could legitimately be None: _write_prev_branch(root, None) would have written the literal string "None" into PREV_BRANCH (now guarded via _maybe_write_prev_branch, skips writing when there's no branch to record); sanitize_display(None) would have raised TypeError since it requires a str (now guarded via _display_branch).

Tests: new tests/test_cmd_switch_detached_head.py, 7 tests covering the exact repro, --json mode, switch to a nonexistent branch (clean structured error, not a crash), switch -c and switch -C from detached (branches off the detached commit, matching git), switch - returning to the last real branch rather than writing "None", and confirming muse checkout's existing recovery path is unaffected. All 7 confirmed RED against the unfixed code, GREEN after. Full tests/test_cmd_switch.py + test_cmd_checkout.py + test_core_refs.py regression suite: 250 passed, zero regressions.

Verified against the real installed CLI (not just the test suite): the exact terminal repro from the ticket now exits 0 with "Switched to branch 'main'", no traceback.

sha256:dfb030d712110599faeb978c6b5eaa4534b9544477860a1e44542d41fdb37abd sha
+21 ~2 symbols
2 changed · 1209 in snapshot files
sha256:f52fedce73c2ee9f11eabf2e011320edfadefbb69e91b90b32cc80369db74e64 snapshot
+21
symbols added
~2
symbols modified
2
files changed
1209
files in snapshot
0
dead code introduced
Semantic Changes 23 symbols
+ _run function function _run L28–34
+ detached_repo function function detached_repo L38–52
+ CliRunner import import CliRunner L22–22
+ InvokeResult import import InvokeResult L22–22
+ annotations import import annotations L14–14
+ get_head_commit_id import import get_head_commit_id L23–23
+ json import import json L16–16
+ os import import os L17–17
+ pathlib import import pathlib L18–18
+ pytest import import pytest L20–20
+ read_head import import read_head L23–23
+ runner variable variable runner L25–25
+ test_checkout_recovery_path_still_works function function test_checkout_recovery_path_still_works L132–139
+ test_switch_create_from_detached_head_branches_off_current_commit function function test_switch_create_from_detached_head_branches_off_current_commit L87–99
+ test_switch_dash_from_detached_head_returns_to_real_previous_branch function function test_switch_dash_from_detached_head_returns_to_real_previous_branch L112–129
+ test_switch_force_create_from_detached_head function function test_switch_force_create_from_detached_head L102–109
+ test_switch_to_existing_branch_from_detached_head_json function function test_switch_to_existing_branch_from_detached_head_json L66–74
+ test_switch_to_existing_branch_from_detached_head_succeeds function function test_switch_to_existing_branch_from_detached_head_succeeds L55–63
+ test_switch_to_nonexistent_branch_from_detached_head_clean_error function function test_switch_to_nonexistent_branch_from_detached_head_clean_error L77–84
~ muse/cli/commands/switch.py .py 2 symbols added, 2 symbols modified
+ _display_branch function function _display_branch L128–131
+ _maybe_write_prev_branch function function _maybe_write_prev_branch L120–126
~ run
Files Changed
+1 ~1
1209 in snapshot
← Older Oldest on fix/106-switch-detached-head
All commits
Newer → Latest on fix/106-switch-detached-head

0 comments

No comments yet. Be the first to start the discussion.

To add a comment, use the Muse CLI: muse hub commit comment sha256:dfb030d712110599faeb978c6b5eaa4534b9544477860a1e44542d41fdb37abd --body "your comment"