gabriel / muse public
Closed #106 Bug
filed by gabriel human · 31 days ago

muse switch crashes with unhandled traceback from detached HEAD (git allows this cleanly)

0 Anchors
— Blast radius
— Churn 30d
0 Proposals

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, then muse switch <existing-branch> raises ValueError / SystemExit is not raised cleanly — pin down the current, broken behavior before touching any code).
    • Audit every use of current_branch later in switch.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 to read_head() or needs per-call-site guards, matching checkout.py's pattern.
  • Phase 2 — Design decision
    • Confirm with gabriel: should switch.py import and reuse read_head() directly (matching checkout.py), or should read_current_branch() itself gain an optional raise_on_detached: bool = True parameter 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 matching checkout.py exactly (reuse read_head(), don't touch the widely-used raising function).
  • Phase 3 — Implement
    • Replace switch.py:378's unguarded read_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's git switch - from detached HEAD returns to the branch you were on before detaching, not an error.
  • 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-branch from detached HEAD (creates from the detached commit, matching git's behavior of branching off wherever HEAD currently points); --json mode 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 of read_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>, then muse 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.
  • 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/main to local/staging/production.

Acceptance criteria

  • muse switch <existing-branch> from a detached HEAD state succeeds cleanly, matching git switch/git checkout's behavior exactly — no traceback, no special recovery command required.
  • Every error path in switch.py that 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.
◎ Activity1
●
gabriel opened this issue 31 days ago
○
gabriel 2 days ago

Fixed and shipped to source (not yet in a published release — see note below).

Root cause confirmed exactly as filed: switch.py:378's unguarded read_current_branch() call raised ValueError on detached HEAD, propagating as a raw traceback instead of any of switch.py's own structured _emit_error() calls.

Followed the ticket's own phased plan:

  • Phase 1 audit: grepped all read_current_branch() call sites — 100+ across the codebase, all relying on it raising. Confirmed the ticket's own Phase 2 guidance applies: reuse read_head() (matching checkout.py's existing tolerant pattern) rather than widening the shared raising function.
  • Phase 2 design: went with option B (reuse read_head()) per that audit result.
  • Phase 3 implement: fixed both affected call sites — the main run() path (line 378) and the -C force-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-None bugs while wiring the fix through: _write_prev_branch(root, None) would have written the literal string "None" into PREV_BRANCH; sanitize_display(None) would have raised TypeError. Both guarded (_maybe_write_prev_branch, _display_branch).
  • Phase 4 green: new tests/test_cmd_switch_detached_head.py, 7 tests — exact repro, --json mode, switch to a nonexistent branch (clean structured error, not a crash), switch -c/switch -C from 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. Full test_cmd_switch.py + test_cmd_checkout.py + test_core_refs.py regression suite: 250 passed, zero regressions.
  • Phase 5 real-world verification: reproduced the exact terminal repro from this ticket against the real installed CLI before the fix (confirmed the crash verbatim), then again after — muse switch main from detached HEAD now exits 0 with Switched to branch 'main', no traceback. Also manually verified switch -c, switch -C, and switch - from detached HEAD.

Committed, merged to dev/main, pushed to local/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 muse release (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.

✓
closed this issue 2 days ago