"""``muse bisect`` — binary search through commit history to find regressions. ``muse bisect`` is Muse's power-tool for regression hunting. Given a known-bad commit and a known-good commit it performs a binary search through the history between them, asking at each midpoint: *"does the bug exist here?"* until the first bad commit is isolated. It is fully agent-safe: ``muse bisect run `` automates the search by running an arbitrary command at each step and interpreting the exit code: 0 → good (bug not present) 125 → skip (commit untestable) else → bad (bug present) Symbol-scoped bisect -------------------- Pass ``--symbol addr`` to ``muse bisect start`` to restrict the candidate commit list to only commits whose structured_delta touched that symbol. This can reduce a 9-step bisect over 300 commits to a 3-step bisect over the 8 commits that actually changed the symbol you care about. muse bisect start --bad HEAD --good v1.0.0 \\ --symbol billing.py::Invoice.compute_total At each step, Muse shows which ops the midpoint commit applied to the symbol so you know exactly what changed before you run your tests. JSON output ----------- Pass ``--json`` to any subcommand for machine-readable NDJSON output. The ``run`` subcommand emits one JSON object per bisect step (NDJSON), plus a final summary line. All other subcommands emit a single JSON object on stdout. Subcommands:: muse bisect start [--bad ] [--good ] [--symbol ] [--json] muse bisect bad [] [--json] muse bisect good [] [--json] muse bisect skip [] [--json] muse bisect run [--json] muse bisect log [--json] muse bisect reset [--json] Exit codes:: 0 — success 1 — user error (no session, bad ref, bad args) 2 — internal error (lost state) """ from __future__ import annotations import argparse import json import logging import pathlib import sys from typing import TypedDict from muse.core.bisect import ( BisectResult, _commits_touching_symbol, _symbol_ops_in_commit, get_bisect_log, get_bisect_next, is_bisect_active, mark_bad, mark_good, reset_bisect, run_bisect_command, skip_commit, start_bisect, ) from muse.core.errors import ExitCode from muse.core.repo import read_repo_id, require_repo from muse.core.store import get_head_commit_id, read_current_branch, resolve_commit_ref from muse.core.validation import sanitize_display logger = logging.getLogger(__name__) _MAX_SYMBOL_ADDR_LEN = 500 # --------------------------------------------------------------------------- # Typed JSON schemas # --------------------------------------------------------------------------- class _BisectStepJson(TypedDict): """JSON output for start / bad / good / skip subcommands.""" done: bool first_bad: str | None next_to_test: str | None remaining_count: int steps_remaining: int verdict: str symbol_changes: list[str] class _BisectRunStepJson(TypedDict): """One NDJSON line emitted by ``muse bisect run --json`` per step.""" step: int testing: str verdict: str remaining_count: int done: bool class _BisectRunDoneJson(TypedDict): """Final NDJSON line emitted by ``muse bisect run --json`` when complete.""" done: bool first_bad: str | None steps_taken: int class _BisectLogJson(TypedDict): """JSON output for ``muse bisect log --json``.""" active: bool entries: list[str] class _BisectResetJson(TypedDict): """JSON output for ``muse bisect reset --json``.""" reset: bool # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _resolve_ref(root: pathlib.Path, ref: str | None) -> str: """Resolve *ref* to a full commit ID; fall back to HEAD when *ref* is None.""" branch = read_current_branch(root) repo_id = read_repo_id(root) if ref is None: commit_id = get_head_commit_id(root, branch) if not commit_id: print("❌ No commits on current branch.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) return commit_id commit = resolve_commit_ref(root, repo_id, branch, ref) if commit is None: print(f"❌ Ref '{sanitize_display(ref)}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) return commit.commit_id def _print_result(result: BisectResult) -> None: """Render a BisectResult as human-readable text to stdout.""" if result.done: print(f"\n✅ First bad commit found: {sanitize_display(result.first_bad or '')}") print(" Run 'muse bisect reset' to end the session.") else: print( f"Next to test: {sanitize_display(result.next_to_test or '')} " f"({result.remaining_count} remaining, ~{result.steps_remaining} step(s) left)" ) if result.symbol_changes: print(" Symbol changes in this commit:") for line in result.symbol_changes: print(f" {sanitize_display(line)}") def _result_to_json(result: BisectResult) -> _BisectStepJson: """Convert a BisectResult to a typed JSON-serialisable dict.""" return _BisectStepJson( done=result.done, first_bad=result.first_bad, next_to_test=result.next_to_test, remaining_count=result.remaining_count, steps_remaining=result.steps_remaining, verdict=result.verdict, symbol_changes=[sanitize_display(s) for s in result.symbol_changes], ) # --------------------------------------------------------------------------- # Subcommand handlers # --------------------------------------------------------------------------- def run_bisect_start(args: argparse.Namespace) -> None: """Start a bisect session. Mark the first bad and last good commits. Muse immediately suggests the midpoint commit to test. Use ``--symbol`` to restrict the search to commits that touched a specific symbol, dramatically reducing the number of steps when the regressing symbol is already known. All string fields in text and JSON output are sanitized — ANSI control sequences are stripped. JSON schema:: { "done": true | false, "first_bad": "" | null, "next_to_test": "" | null, "remaining_count": , "steps_remaining": , "verdict": "started", "symbol_changes": ["", ...] } Exit codes: 0 — session started (or immediately resolved when bad/good are adjacent) 1 — session already active, invalid --symbol format, no --good provided, or ref not found 2 — not inside a Muse repository Examples:: muse bisect start --bad HEAD --good v1.0.0 muse bisect start --bad a1b2c3 --good d4e5f6 --good g7h8i9 muse bisect start --bad HEAD --good v1.0.0 --symbol billing.py::Invoice.compute_total muse bisect start --bad HEAD --good v1.0.0 --json """ bad: str | None = args.bad good: list[str] | None = args.good symbol: str | None = args.symbol output_json: bool = args.output_json root = require_repo() if is_bisect_active(root): print( "⚠️ A bisect session is already active. Run 'muse bisect reset' first.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if symbol is not None: if "::" not in symbol: print( f"❌ --symbol must be a qualified symbol address " f"(e.g. billing.py::func), got: {sanitize_display(symbol)!r}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if len(symbol) > _MAX_SYMBOL_ADDR_LEN: print( f"❌ --symbol address too long (max {_MAX_SYMBOL_ADDR_LEN} chars).", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) bad_id = _resolve_ref(root, bad) good_ids = [_resolve_ref(root, g) for g in (good or [])] if not good_ids: print( "❌ Provide at least one --good commit: " "muse bisect start --bad HEAD --good ", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) branch = read_current_branch(root) result = start_bisect(root, bad_id, good_ids, branch=branch, symbol_filter=symbol or "") if output_json: print(json.dumps(_result_to_json(result))) return symbol_msg = f" symbol={sanitize_display(symbol)}" if symbol else "" print( f"Bisect session started. bad={bad_id[:12]} " f"good=[{', '.join(g[:12] for g in good_ids)}]{symbol_msg}" ) if symbol and result.remaining_count == 0 and not result.done: print( f"⚠️ No commits between bad and good touched symbol " f"{sanitize_display(symbol)!r}." ) print(" Try bisecting without --symbol, or widen the bad/good range.") _print_result(result) def run_bisect_bad(args: argparse.Namespace) -> None: """Mark a commit as bad (the bug is present in this commit). Narrows the bisect search range by recording that the given commit (default: HEAD) exhibits the regression. Muse updates the remaining set and suggests the next midpoint to test. All string fields in text and JSON output are sanitized — ANSI control sequences are stripped. JSON schema:: { "done": true | false, "first_bad": "" | null, "next_to_test": "" | null, "remaining_count": , "steps_remaining": , "verdict": "bad", "symbol_changes": ["", ...] } Exit codes: 0 — verdict recorded; search advanced (or done=true if isolated) 1 — no active bisect session, or ref not found 2 — not inside a Muse repository Examples:: muse bisect bad muse bisect bad a1b2c3 muse bisect bad --json muse bisect bad a1b2c3 --json """ ref: str | None = args.ref output_json: bool = args.output_json root = require_repo() if not is_bisect_active(root): print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) commit_id = _resolve_ref(root, ref) result = mark_bad(root, commit_id) if output_json: print(json.dumps(_result_to_json(result))) return print(f"Marked {commit_id[:12]} as bad.") _print_result(result) def run_bisect_good(args: argparse.Namespace) -> None: """Mark a commit as good (the bug is absent in this commit). Narrows the bisect search range by recording that the given commit (default: HEAD) does not exhibit the regression. Muse updates the remaining set and suggests the next midpoint to test. All string fields in text and JSON output are sanitized — ANSI control sequences are stripped. JSON schema:: { "done": true | false, "first_bad": "" | null, "next_to_test": "" | null, "remaining_count": , "steps_remaining": , "verdict": "good", "symbol_changes": ["", ...] } Exit codes: 0 — verdict recorded; search advanced (or done=true if isolated) 1 — no active bisect session, or ref not found 2 — not inside a Muse repository Examples:: muse bisect good muse bisect good a1b2c3 muse bisect good --json muse bisect good a1b2c3 --json """ ref: str | None = args.ref output_json: bool = args.output_json root = require_repo() if not is_bisect_active(root): print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) commit_id = _resolve_ref(root, ref) result = mark_good(root, commit_id) if output_json: print(json.dumps(_result_to_json(result))) return print(f"Marked {commit_id[:12]} as good.") _print_result(result) def run_bisect_skip(args: argparse.Namespace) -> None: """Skip a commit that cannot be tested. Records that the given commit (default: HEAD) is untestable — for example because it fails to build. Muse excludes it from the remaining set and suggests the next midpoint. In ``muse bisect run`` mode, exit code 125 from the test script triggers this automatically. All string fields in text and JSON output are sanitized — ANSI control sequences are stripped. JSON schema:: { "done": true | false, "first_bad": "" | null, "next_to_test": "" | null, "remaining_count": , "steps_remaining": , "verdict": "skip", "symbol_changes": ["", ...] } Exit codes: 0 — commit skipped; search advanced (or done=true if isolated) 1 — no active bisect session, or ref not found 2 — not inside a Muse repository Examples:: muse bisect skip muse bisect skip a1b2c3 muse bisect skip --json muse bisect skip a1b2c3 --json """ ref: str | None = args.ref output_json: bool = args.output_json root = require_repo() if not is_bisect_active(root): print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) commit_id = _resolve_ref(root, ref) result = skip_commit(root, commit_id) if output_json: print(json.dumps(_result_to_json(result))) return print(f"Skipped {commit_id[:12]}.") _print_result(result) def run_bisect_run(args: argparse.Namespace) -> None: """Automatically bisect by running a command at each step. The command exit code determines the verdict:: 0 → good 125 → skip else → bad The command is run in the repository root as a shell command. Muse automatically applies verdicts and advances until the first bad commit is isolated. With ``--json`` the command emits NDJSON: one step line per tested commit, followed by a final summary line. All string fields in both text and JSON output are sanitized — ANSI control sequences are stripped. NDJSON step line schema (one per commit tested):: { "step": , "testing": "", "verdict": "good" | "bad" | "skip", "remaining_count": , "done": true | false } NDJSON final summary line schema:: { "done": true, "first_bad": "" | null, "steps_taken": } Exit codes: 0 — bisect run completed; first bad commit identified or session exhausted 1 — no active bisect session 2 — not inside a Muse repository Examples:: muse bisect run "pytest tests/test_regression.py -x -q" muse bisect run "make test" --json muse bisect run "./check.sh" --json """ command: str = args.command output_json: bool = args.output_json root = require_repo() if not is_bisect_active(root): print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) step = 0 while True: current, symbol_filter = get_bisect_next(root) if current is None: if output_json: print(json.dumps(_BisectRunDoneJson(done=True, first_bad=None, steps_taken=step))) else: print("✅ Bisect complete. Run 'muse bisect reset' to end.") return if not output_json: print(f" → Testing {current[:12]} …") if symbol_filter: changes = _symbol_ops_in_commit(root, current, symbol_filter) if changes: print(" Symbol changes:") for line in changes: print(f" {sanitize_display(line)}") result = run_bisect_command(root, command, current) step += 1 if output_json: step_payload = _BisectRunStepJson( step=step, testing=current, verdict=result.verdict, remaining_count=result.remaining_count, done=result.done, ) print(json.dumps(step_payload)) else: print(f" verdict: {result.verdict}") if result.done: if output_json: print(json.dumps( _BisectRunDoneJson( done=True, first_bad=result.first_bad, steps_taken=step, ) )) else: print(f"\n✅ First bad commit: {sanitize_display(result.first_bad or '')}") return def run_bisect_log(args: argparse.Namespace) -> None: """Show the full bisect session log. Displays every verdict applied so far, oldest first. Each log entry is a space-separated triple: `` ``. Works whether or not a session is currently active — returns an empty list when no session has been started. All string fields in text and JSON output are sanitized — ANSI control sequences are stripped. JSON schema:: { "active": true | false, "entries": [" ", ...] } Exit codes: 0 — always (empty entries when no session exists) 2 — not inside a Muse repository Examples:: muse bisect log muse bisect log --json """ output_json: bool = args.output_json root = require_repo() entries = get_bisect_log(root) active = is_bisect_active(root) if output_json: print(json.dumps(_BisectLogJson( active=active, entries=[sanitize_display(e) for e in entries], ))) return if not entries: print("No bisect log. Start a session with 'muse bisect start'.") return print("Bisect log:") for entry in entries: print(f" {sanitize_display(entry)}") def run_bisect_reset(args: argparse.Namespace) -> None: """End the bisect session and remove all bisect state. Idempotent — safe to call whether or not a session is currently active. After reset, ``muse bisect log`` reports ``active=false`` and ``muse bisect bad/good/skip`` will refuse until a new session is started. JSON schema:: {"reset": true} Exit codes: 0 — always (state removed if it existed, no-op otherwise) 2 — not inside a Muse repository Examples:: muse bisect reset muse bisect reset --json """ output_json: bool = args.output_json root = require_repo() reset_bisect(root) if output_json: print(json.dumps(_BisectResetJson(reset=True))) return print("Bisect session reset.") # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the ``bisect`` subcommand.""" parser = subparsers.add_parser( "bisect", help="Binary search through commit history to find regressions.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") subs.required = True # ── bad ─────────────────────────────────────────────────────────────────── bad_p = subs.add_parser( "bad", help="Mark a commit as bad (bug present).", description=( "Record that the given commit (default: HEAD) exhibits the\n" "regression. Muse narrows the search range and suggests the\n" "next midpoint to test.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect bad --json\n" " muse bisect bad --json\n\n" "JSON output schema\n" "------------------\n" ' {"done": false, "first_bad": null, "next_to_test": "",\n' ' "remaining_count": , "steps_remaining": ,\n' ' "verdict": "bad", "symbol_changes": [...]}\n\n' "Exit codes\n" "----------\n" " 0 — verdict recorded (done=true when first bad commit isolated)\n" " 1 — no active bisect session, or ref not found\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) bad_p.add_argument( "ref", nargs="?", default=None, metavar="REF", help="Commit to mark bad (default: HEAD).", ) bad_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) bad_p.set_defaults(func=run_bisect_bad) # ── good ────────────────────────────────────────────────────────────────── good_p = subs.add_parser( "good", help="Mark a commit as good (bug absent).", description=( "Record that the given commit (default: HEAD) does not exhibit\n" "the regression. Muse narrows the search range and suggests the\n" "next midpoint to test.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect good --json\n" " muse bisect good --json\n\n" "JSON output schema\n" "------------------\n" ' {"done": false, "first_bad": null, "next_to_test": "",\n' ' "remaining_count": , "steps_remaining": ,\n' ' "verdict": "good", "symbol_changes": [...]}\n\n' "Exit codes\n" "----------\n" " 0 — verdict recorded (done=true when first bad commit isolated)\n" " 1 — no active bisect session, or ref not found\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) good_p.add_argument( "ref", nargs="?", default=None, metavar="REF", help="Commit to mark good (default: HEAD).", ) good_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) good_p.set_defaults(func=run_bisect_good) # ── log ─────────────────────────────────────────────────────────────────── log_p = subs.add_parser( "log", help="Show the bisect session log.", description=( "Display every verdict applied in the current (or most recent)\n" "bisect session, oldest first. Each entry is a space-separated\n" "triple: .\n\n" "Agent quickstart\n" "----------------\n" " muse bisect log --json\n\n" "JSON output schema\n" "------------------\n" ' {"active": true|false,\n' ' "entries": [" ", ...]}\n\n' "Exit codes\n" "----------\n" " 0 — always (empty entries list when no session exists)\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) log_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) log_p.set_defaults(func=run_bisect_log) # ── reset ───────────────────────────────────────────────────────────────── reset_p = subs.add_parser( "reset", help="End the bisect session and clean up state.", description=( "Remove all bisect state. Idempotent — safe to call whether or\n" "not a session is active. After reset, bad/good/skip commands\n" "will refuse until a new session is started with 'muse bisect\n" "start'.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect reset --json\n\n" "JSON output schema\n" "------------------\n" ' {"reset": true}\n\n' "Exit codes\n" "----------\n" " 0 — always (no-op when no session is active)\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) reset_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) reset_p.set_defaults(func=run_bisect_reset) # ── run ─────────────────────────────────────────────────────────────────── run_p = subs.add_parser( "run", help="Automatically bisect by running a command.", description=( "Run COMMAND at each bisect step; Muse interprets the exit code\n" "and applies the verdict automatically until the first bad commit\n" "is isolated. Exit codes: 0=good, 125=skip, anything else=bad.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect run 'pytest tests/test_regression.py -x -q' --json\n" " muse bisect run './check.sh' --json\n\n" "NDJSON output — one step line per commit, then a summary line\n" "-----------------------------------------------------------\n" ' step line: {"step":, "testing":"", "verdict":"good|bad|skip",\n' ' "remaining_count":, "done":false}\n' ' done line: {"done":true, "first_bad":"|null", "steps_taken":}\n\n' "Exit codes\n" "----------\n" " 0 — run complete (first bad isolated or session exhausted)\n" " 1 — no active bisect session\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) run_p.add_argument( "command", metavar="COMMAND", help="Shell command to run at each step (exit 0=good, 125=skip, else=bad).", ) run_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable NDJSON (one line per step, then a summary line).", ) run_p.set_defaults(func=run_bisect_run) # ── skip ────────────────────────────────────────────────────────────────── skip_p = subs.add_parser( "skip", help="Skip an untestable commit.", description=( "Record that the given commit (default: HEAD) cannot be tested —\n" "e.g. it fails to build. Muse excludes it from the remaining set\n" "and suggests the next midpoint. In 'muse bisect run' mode, exit\n" "code 125 from the test script triggers this automatically.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect skip --json\n" " muse bisect skip --json\n\n" "JSON output schema\n" "------------------\n" ' {"done": false, "first_bad": null, "next_to_test": "",\n' ' "remaining_count": , "steps_remaining": ,\n' ' "verdict": "skip", "symbol_changes": [...]}\n\n' "Exit codes\n" "----------\n" " 0 — commit skipped (done=true when first bad commit isolated)\n" " 1 — no active bisect session, or ref not found\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) skip_p.add_argument( "ref", nargs="?", default=None, metavar="REF", help="Commit to skip (default: HEAD).", ) skip_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) skip_p.set_defaults(func=run_bisect_skip) # ── start ───────────────────────────────────────────────────────────────── start_p = subs.add_parser( "start", help="Begin a bisect session.", description=( "Mark the known-bad and known-good commits, then let Muse binary-\n" "search the commits between them. Muse immediately suggests the\n" "midpoint commit to test. Use --symbol to restrict the search to\n" "commits that touched a specific symbol.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect start --bad HEAD --good v1.0.0 --json\n" " muse bisect start --bad HEAD --good v1.0.0 \\\n" " --symbol billing.py::Invoice.compute_total --json\n\n" "JSON output schema\n" "------------------\n" ' {"done": false, "first_bad": null, "next_to_test": "",\n' ' "remaining_count": , "steps_remaining": ,\n' ' "verdict": "started", "symbol_changes": [...]}\n\n' "Exit codes\n" "----------\n" " 0 — session started (or immediately resolved when bad/good adjacent)\n" " 1 — session already active, bad --symbol, missing --good, or bad ref\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) start_p.add_argument( "--bad", default=None, metavar="REF", help="Known-bad commit (default: HEAD).", ) start_p.add_argument( "--good", nargs="*", default=None, metavar="REF", help="Known-good commit(s). Repeat for multiple: --good v1.0 --good v0.9.", ) start_p.add_argument( "--symbol", "-s", default=None, metavar="ADDR", help=( "Restrict search to commits that touched this symbol " "(e.g. billing.py::Invoice.compute_total). Dramatically reduces " "the number of steps when you already know which symbol regressed." ), ) start_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) start_p.set_defaults(func=run_bisect_start)