bisect.py
python
| 1 | """muse bisect — binary search for the commit that introduced a regression. |
| 2 | |
| 3 | Music-domain analogue of ``git bisect``. Given a known-good and a known-bad |
| 4 | commit on the history of a Muse repository, this command binary-searches the |
| 5 | ancestry path to identify the exact commit that first introduced a rhythmic |
| 6 | drift, mix regression, or other quality regression. |
| 7 | |
| 8 | Subcommands |
| 9 | ----------- |
| 10 | ``muse bisect start`` |
| 11 | Begin a bisect session. Records the pre-bisect HEAD ref in |
| 12 | ``.muse/BISECT_STATE.json`` so ``reset`` can restore it. Blocked if a |
| 13 | merge is in progress (``.muse/MERGE_STATE.json`` exists). |
| 14 | |
| 15 | ``muse bisect good <commit>`` |
| 16 | Mark *commit* as known-good. If both good and bad are set, checks out |
| 17 | the midpoint commit into muse-work/ and reports how many steps remain. |
| 18 | |
| 19 | ``muse bisect bad <commit>`` |
| 20 | Mark *commit* as known-bad. Same auto-advance logic as ``good``. |
| 21 | |
| 22 | ``muse bisect run <cmd>`` |
| 23 | Automate the bisect loop. Runs *cmd* in a shell after each checkout; |
| 24 | exit 0 → good, exit 1 (or non-zero) → bad. Stops when the culprit is |
| 25 | identified. |
| 26 | |
| 27 | ``muse bisect reset`` |
| 28 | End the session: restore ``.muse/HEAD`` and muse-work/ to the |
| 29 | pre-bisect state, then remove BISECT_STATE.json. |
| 30 | |
| 31 | ``muse bisect log`` |
| 32 | Print the bisect log (what has been tested and with what verdict). |
| 33 | |
| 34 | Session state |
| 35 | ------------- |
| 36 | Persisted in ``.muse/BISECT_STATE.json`` so the session survives across shell |
| 37 | invocations. ``muse bisect start`` blocks if the file already exists. |
| 38 | |
| 39 | Exit codes |
| 40 | ---------- |
| 41 | 0 — success (or culprit identified) |
| 42 | 1 — user error (bad args, session already active, commit not found) |
| 43 | 2 — not a Muse repository |
| 44 | 3 — internal error |
| 45 | """ |
| 46 | from __future__ import annotations |
| 47 | |
| 48 | import asyncio |
| 49 | import json |
| 50 | import logging |
| 51 | import math |
| 52 | import pathlib |
| 53 | import shlex |
| 54 | import subprocess |
| 55 | |
| 56 | import typer |
| 57 | from sqlalchemy.ext.asyncio import AsyncSession |
| 58 | |
| 59 | from maestro.muse_cli._repo import require_repo |
| 60 | from maestro.muse_cli.db import open_session |
| 61 | from maestro.muse_cli.errors import ExitCode |
| 62 | from maestro.muse_cli.object_store import read_object |
| 63 | from maestro.services.muse_bisect import ( |
| 64 | BisectState, |
| 65 | BisectStepResult, |
| 66 | advance_bisect, |
| 67 | clear_bisect_state, |
| 68 | get_commits_between, |
| 69 | pick_midpoint, |
| 70 | read_bisect_state, |
| 71 | write_bisect_state, |
| 72 | ) |
| 73 | |
| 74 | logger = logging.getLogger(__name__) |
| 75 | |
| 76 | app = typer.Typer(help="Binary search for the commit that introduced a regression.") |
| 77 | |
| 78 | # Minimum abbreviated commit SHA length accepted as user input. |
| 79 | _MIN_SHA_PREFIX = 4 |
| 80 | |
| 81 | |
| 82 | # --------------------------------------------------------------------------- |
| 83 | # Helpers |
| 84 | # --------------------------------------------------------------------------- |
| 85 | |
| 86 | |
| 87 | def _resolve_commit_id(root: pathlib.Path, ref: str) -> str: |
| 88 | """Resolve *ref* to a full commit ID from filesystem refs. |
| 89 | |
| 90 | Accepts: |
| 91 | - ``"HEAD"`` — reads ``.muse/HEAD`` → resolves the symbolic ref. |
| 92 | - A branch name — reads ``.muse/refs/heads/<branch>``. |
| 93 | - An abbreviated or full commit SHA — returned as-is (validated later). |
| 94 | |
| 95 | Args: |
| 96 | root: Repository root. |
| 97 | ref: Commit reference string from the user. |
| 98 | |
| 99 | Returns: |
| 100 | The commit ID string (may be an abbreviation; DB validates). |
| 101 | """ |
| 102 | muse_dir = root / ".muse" |
| 103 | |
| 104 | if ref.upper() == "HEAD": |
| 105 | head_content = (muse_dir / "HEAD").read_text().strip() |
| 106 | if head_content.startswith("refs/"): |
| 107 | ref_path = muse_dir / pathlib.Path(head_content) |
| 108 | return ref_path.read_text().strip() if ref_path.exists() else head_content |
| 109 | return head_content |
| 110 | |
| 111 | # Try branch name first. |
| 112 | branch_path = muse_dir / "refs" / "heads" / ref |
| 113 | if branch_path.exists(): |
| 114 | return branch_path.read_text().strip() |
| 115 | |
| 116 | # Assume it's a commit SHA. |
| 117 | return ref |
| 118 | |
| 119 | |
| 120 | async def _checkout_snapshot_into_workdir( |
| 121 | session: AsyncSession, |
| 122 | root: pathlib.Path, |
| 123 | commit_id: str, |
| 124 | ) -> int: |
| 125 | """Hydrate muse-work/ from the snapshot attached to *commit_id*. |
| 126 | |
| 127 | Reads the snapshot manifest from the DB, then writes each object from |
| 128 | ``.muse/objects/`` into muse-work/ (resetting the directory first). |
| 129 | |
| 130 | Returns the number of files written (0 if the snapshot is empty). |
| 131 | |
| 132 | Args: |
| 133 | session: Open async DB session. |
| 134 | root: Repository root. |
| 135 | commit_id: Target commit whose snapshot to check out. |
| 136 | """ |
| 137 | from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot |
| 138 | |
| 139 | commit: MuseCliCommit | None = await session.get(MuseCliCommit, commit_id) |
| 140 | if commit is None: |
| 141 | typer.echo(f"❌ Commit {commit_id[:8]} not found in database.") |
| 142 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 143 | |
| 144 | snapshot: MuseCliSnapshot | None = await session.get(MuseCliSnapshot, commit.snapshot_id) |
| 145 | if snapshot is None: |
| 146 | typer.echo( |
| 147 | f"❌ Snapshot {commit.snapshot_id[:8]} for commit {commit_id[:8]} " |
| 148 | "not found in database." |
| 149 | ) |
| 150 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 151 | |
| 152 | manifest: dict[str, str] = dict(snapshot.manifest) |
| 153 | |
| 154 | workdir = root / "muse-work" |
| 155 | |
| 156 | # Clear muse-work/ before populating. |
| 157 | if workdir.exists(): |
| 158 | for existing_file in sorted(workdir.rglob("*")): |
| 159 | if existing_file.is_file(): |
| 160 | existing_file.unlink() |
| 161 | for d in sorted(workdir.rglob("*"), reverse=True): |
| 162 | if d.is_dir(): |
| 163 | try: |
| 164 | d.rmdir() |
| 165 | except OSError: |
| 166 | pass |
| 167 | |
| 168 | workdir.mkdir(parents=True, exist_ok=True) |
| 169 | |
| 170 | files_written = 0 |
| 171 | for rel_path, object_id in sorted(manifest.items()): |
| 172 | content = read_object(root, object_id) |
| 173 | if content is None: |
| 174 | logger.warning("⚠️ Object %s missing from local store; skipping", object_id[:8]) |
| 175 | continue |
| 176 | dest = workdir / rel_path |
| 177 | dest.parent.mkdir(parents=True, exist_ok=True) |
| 178 | dest.write_bytes(content) |
| 179 | files_written += 1 |
| 180 | |
| 181 | return files_written |
| 182 | |
| 183 | |
| 184 | # --------------------------------------------------------------------------- |
| 185 | # start |
| 186 | # --------------------------------------------------------------------------- |
| 187 | |
| 188 | |
| 189 | @app.command("start") |
| 190 | def bisect_start() -> None: |
| 191 | """Begin a bisect session from the current HEAD. |
| 192 | |
| 193 | Records the pre-bisect HEAD ref and commit ID in BISECT_STATE.json. |
| 194 | Fails if a bisect or merge is already in progress. |
| 195 | """ |
| 196 | root = require_repo() |
| 197 | muse_dir = root / ".muse" |
| 198 | |
| 199 | # Guard: block if merge in progress. |
| 200 | merge_state_path = muse_dir / "MERGE_STATE.json" |
| 201 | if merge_state_path.exists(): |
| 202 | typer.echo("❌ Merge in progress. Resolve it before starting bisect.") |
| 203 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 204 | |
| 205 | # Guard: block if bisect already active. |
| 206 | existing = read_bisect_state(root) |
| 207 | if existing is not None: |
| 208 | typer.echo( |
| 209 | "❌ Bisect already in progress.\n" |
| 210 | " Run 'muse bisect reset' to end the current session first." |
| 211 | ) |
| 212 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 213 | |
| 214 | # Capture current HEAD. |
| 215 | head_ref = (muse_dir / "HEAD").read_text().strip() # e.g. "refs/heads/main" |
| 216 | pre_bisect_commit = "" |
| 217 | if head_ref.startswith("refs/"): |
| 218 | ref_path = muse_dir / pathlib.Path(head_ref) |
| 219 | if ref_path.exists(): |
| 220 | pre_bisect_commit = ref_path.read_text().strip() |
| 221 | else: |
| 222 | pre_bisect_commit = head_ref # detached HEAD — store the commit ID directly |
| 223 | |
| 224 | state = BisectState( |
| 225 | good=None, |
| 226 | bad=None, |
| 227 | current=None, |
| 228 | tested={}, |
| 229 | pre_bisect_ref=head_ref, |
| 230 | pre_bisect_commit=pre_bisect_commit, |
| 231 | ) |
| 232 | write_bisect_state(root, state) |
| 233 | |
| 234 | typer.echo( |
| 235 | "✅ Bisect session started.\n" |
| 236 | " Now mark a good commit: muse bisect good <commit>\n" |
| 237 | " And a bad commit: muse bisect bad <commit>" |
| 238 | ) |
| 239 | logger.info("✅ muse bisect start (pre_bisect_ref=%r commit=%s)", head_ref, pre_bisect_commit[:8] if pre_bisect_commit else "none") |
| 240 | |
| 241 | |
| 242 | # --------------------------------------------------------------------------- |
| 243 | # good / bad (shared implementation) |
| 244 | # --------------------------------------------------------------------------- |
| 245 | |
| 246 | |
| 247 | def _bisect_mark(root: pathlib.Path, ref: str, verdict: str) -> None: |
| 248 | """Core logic for ``muse bisect good`` and ``muse bisect bad``. |
| 249 | |
| 250 | Resolves *ref* to a commit ID, records the verdict, advances the binary |
| 251 | search, and checks out the next midpoint into muse-work/. |
| 252 | |
| 253 | Args: |
| 254 | root: Repository root. |
| 255 | ref: Commit reference from the user (SHA, branch name, ``HEAD``). |
| 256 | verdict: Either ``"good"`` or ``"bad"``. |
| 257 | """ |
| 258 | state = read_bisect_state(root) |
| 259 | if state is None: |
| 260 | typer.echo("❌ No bisect session in progress. Run 'muse bisect start' first.") |
| 261 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 262 | |
| 263 | commit_id = _resolve_commit_id(root, ref) |
| 264 | if len(commit_id) < _MIN_SHA_PREFIX: |
| 265 | typer.echo(f"❌ Commit ref '{ref}' could not be resolved to a valid commit ID.") |
| 266 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 267 | |
| 268 | async def _run() -> BisectStepResult: |
| 269 | async with open_session() as session: |
| 270 | # Validate commit exists in DB (find by prefix if abbreviated). |
| 271 | from maestro.muse_cli.db import find_commits_by_prefix |
| 272 | from maestro.muse_cli.models import MuseCliCommit |
| 273 | |
| 274 | if len(commit_id) < 64: |
| 275 | matches = await find_commits_by_prefix(session, commit_id) |
| 276 | if not matches: |
| 277 | typer.echo(f"❌ No commit found matching '{commit_id[:8]}'.") |
| 278 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 279 | full_id = matches[0].commit_id |
| 280 | else: |
| 281 | row: MuseCliCommit | None = await session.get(MuseCliCommit, commit_id) |
| 282 | if row is None: |
| 283 | typer.echo(f"❌ Commit {commit_id[:8]} not found in database.") |
| 284 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 285 | full_id = commit_id |
| 286 | |
| 287 | result = await advance_bisect( |
| 288 | session=session, |
| 289 | root=root, |
| 290 | commit_id=full_id, |
| 291 | verdict=verdict, |
| 292 | ) |
| 293 | |
| 294 | # If a next commit is identified, check it out. |
| 295 | if result.next_commit is not None: |
| 296 | files = await _checkout_snapshot_into_workdir( |
| 297 | session, root, result.next_commit |
| 298 | ) |
| 299 | logger.info( |
| 300 | "✅ muse bisect: checked out %s into muse-work/ (%d files)", |
| 301 | result.next_commit[:8], |
| 302 | files, |
| 303 | ) |
| 304 | |
| 305 | return result |
| 306 | |
| 307 | try: |
| 308 | result = asyncio.run(_run()) |
| 309 | except typer.Exit: |
| 310 | raise |
| 311 | except Exception as exc: |
| 312 | typer.echo(f"❌ muse bisect {verdict} failed: {exc}") |
| 313 | logger.error("❌ muse bisect %s error: %s", verdict, exc, exc_info=True) |
| 314 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 315 | |
| 316 | typer.echo(result.message) |
| 317 | |
| 318 | if result.culprit is not None: |
| 319 | logger.info("🎯 muse bisect culprit identified: %s", result.culprit[:8]) |
| 320 | |
| 321 | |
| 322 | @app.command("good") |
| 323 | def bisect_good( |
| 324 | commit: str = typer.Argument( |
| 325 | "HEAD", |
| 326 | help="Commit to mark as good. Accepts HEAD, branch name, full or abbreviated SHA.", |
| 327 | ), |
| 328 | ) -> None: |
| 329 | """Mark a commit as known-good and advance the binary search.""" |
| 330 | root = require_repo() |
| 331 | _bisect_mark(root, commit, "good") |
| 332 | |
| 333 | |
| 334 | @app.command("bad") |
| 335 | def bisect_bad( |
| 336 | commit: str = typer.Argument( |
| 337 | "HEAD", |
| 338 | help="Commit to mark as bad. Accepts HEAD, branch name, full or abbreviated SHA.", |
| 339 | ), |
| 340 | ) -> None: |
| 341 | """Mark a commit as known-bad and advance the binary search.""" |
| 342 | root = require_repo() |
| 343 | _bisect_mark(root, commit, "bad") |
| 344 | |
| 345 | |
| 346 | # --------------------------------------------------------------------------- |
| 347 | # run |
| 348 | # --------------------------------------------------------------------------- |
| 349 | |
| 350 | |
| 351 | @app.command("run") |
| 352 | def bisect_run( |
| 353 | cmd: str = typer.Argument(..., help="Shell command to test each midpoint commit."), |
| 354 | max_steps: int = typer.Option( |
| 355 | 50, |
| 356 | "--max-steps", |
| 357 | help="Safety limit: abort after this many test iterations.", |
| 358 | ), |
| 359 | ) -> None: |
| 360 | """Automate the bisect loop by running a command after each checkout. |
| 361 | |
| 362 | The command is executed in a shell. Exit code 0 → good; any non-zero |
| 363 | exit code → bad. The loop stops when the culprit commit is identified |
| 364 | or when --max-steps iterations are exhausted. |
| 365 | |
| 366 | Music example:: |
| 367 | |
| 368 | muse bisect run python check_groove.py |
| 369 | """ |
| 370 | root = require_repo() |
| 371 | |
| 372 | state = read_bisect_state(root) |
| 373 | if state is None: |
| 374 | typer.echo("❌ No bisect session in progress. Run 'muse bisect start' first.") |
| 375 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 376 | |
| 377 | if state.good is None or state.bad is None: |
| 378 | typer.echo( |
| 379 | "❌ Both good and bad commits must be set before running 'muse bisect run'.\n" |
| 380 | " Mark them first: muse bisect good <commit> / muse bisect bad <commit>" |
| 381 | ) |
| 382 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 383 | |
| 384 | steps = 0 |
| 385 | while steps < max_steps: |
| 386 | steps += 1 |
| 387 | |
| 388 | # Determine the current commit to test. |
| 389 | current_state = read_bisect_state(root) |
| 390 | if current_state is None: |
| 391 | typer.echo("❌ Bisect session disappeared unexpectedly.") |
| 392 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 393 | |
| 394 | if current_state.current is None: |
| 395 | # First iteration: compute the initial midpoint. |
| 396 | # Capture the non-None state fields before entering the nested async scope |
| 397 | # so mypy can reason about them without re-checking the union. |
| 398 | _init_good: str = current_state.good or "" |
| 399 | _init_bad: str = current_state.bad or "" |
| 400 | _init_state: BisectState = current_state |
| 401 | |
| 402 | async def _get_initial() -> BisectStepResult: |
| 403 | async with open_session() as session: |
| 404 | candidates = await get_commits_between(session, _init_good, _init_bad) |
| 405 | mid = pick_midpoint(candidates) |
| 406 | if mid is None: |
| 407 | return BisectStepResult( |
| 408 | culprit=_init_bad, |
| 409 | next_commit=None, |
| 410 | remaining=0, |
| 411 | message=( |
| 412 | f"🎯 Bisect complete! First bad commit: " |
| 413 | f"{_init_bad[:8]}\n" |
| 414 | "Run 'muse bisect reset' to restore your workspace." |
| 415 | ), |
| 416 | ) |
| 417 | # Record current and check it out. |
| 418 | _init_state.current = mid.commit_id |
| 419 | write_bisect_state(root, _init_state) |
| 420 | files = await _checkout_snapshot_into_workdir(session, root, mid.commit_id) |
| 421 | logger.info( |
| 422 | "✅ bisect run: checked out %s (%d files)", mid.commit_id[:8], files |
| 423 | ) |
| 424 | remaining = len(candidates) |
| 425 | est_steps = math.ceil(math.log2(remaining + 1)) if remaining > 0 else 0 |
| 426 | return BisectStepResult( |
| 427 | culprit=None, |
| 428 | next_commit=mid.commit_id, |
| 429 | remaining=remaining, |
| 430 | message=( |
| 431 | f"Checking {mid.commit_id[:8]} " |
| 432 | f"(~{est_steps} step(s), {remaining} in range)" |
| 433 | ), |
| 434 | ) |
| 435 | |
| 436 | try: |
| 437 | init_result = asyncio.run(_get_initial()) |
| 438 | except typer.Exit: |
| 439 | raise |
| 440 | except Exception as exc: |
| 441 | typer.echo(f"❌ muse bisect run (init) failed: {exc}") |
| 442 | logger.error("❌ bisect run init error: %s", exc, exc_info=True) |
| 443 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 444 | |
| 445 | if init_result.culprit is not None: |
| 446 | typer.echo(init_result.message) |
| 447 | return |
| 448 | |
| 449 | typer.echo(init_result.message) |
| 450 | |
| 451 | # Re-read state (current is now set). |
| 452 | current_state = read_bisect_state(root) |
| 453 | if current_state is None or current_state.current is None: |
| 454 | typer.echo("❌ Could not determine current commit to test.") |
| 455 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 456 | |
| 457 | test_commit = current_state.current |
| 458 | typer.echo(f"⟳ Testing {test_commit[:8]}…") |
| 459 | |
| 460 | # Run the user's test command. |
| 461 | proc = subprocess.run(cmd, shell=True, cwd=str(root)) |
| 462 | verdict = "good" if proc.returncode == 0 else "bad" |
| 463 | typer.echo(f" exit={proc.returncode} → {verdict}") |
| 464 | |
| 465 | # Advance the state machine. |
| 466 | async def _advance(cid: str, v: str) -> BisectStepResult: |
| 467 | async with open_session() as session: |
| 468 | result = await advance_bisect(session=session, root=root, commit_id=cid, verdict=v) |
| 469 | if result.next_commit is not None: |
| 470 | await _checkout_snapshot_into_workdir(session, root, result.next_commit) |
| 471 | return result |
| 472 | |
| 473 | try: |
| 474 | step_result = asyncio.run(_advance(test_commit, verdict)) |
| 475 | except typer.Exit: |
| 476 | raise |
| 477 | except Exception as exc: |
| 478 | typer.echo(f"❌ muse bisect run (advance) failed: {exc}") |
| 479 | logger.error("❌ bisect run advance error: %s", exc, exc_info=True) |
| 480 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 481 | |
| 482 | typer.echo(step_result.message) |
| 483 | |
| 484 | if step_result.culprit is not None: |
| 485 | logger.info("🎯 bisect run identified culprit: %s", step_result.culprit[:8]) |
| 486 | return |
| 487 | |
| 488 | typer.echo( |
| 489 | f"⚠️ Safety limit reached ({max_steps} steps). " |
| 490 | "Bisect session is still active; inspect manually." |
| 491 | ) |
| 492 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 493 | |
| 494 | |
| 495 | # --------------------------------------------------------------------------- |
| 496 | # reset |
| 497 | # --------------------------------------------------------------------------- |
| 498 | |
| 499 | |
| 500 | @app.command("reset") |
| 501 | def bisect_reset() -> None: |
| 502 | """End the bisect session and restore the pre-bisect HEAD. |
| 503 | |
| 504 | Restores ``.muse/HEAD`` to the ref it pointed at before ``muse bisect |
| 505 | start`` was called, repopulates muse-work/ from that snapshot (if |
| 506 | objects are available in the local store), and removes BISECT_STATE.json. |
| 507 | """ |
| 508 | root = require_repo() |
| 509 | |
| 510 | state = read_bisect_state(root) |
| 511 | if state is None: |
| 512 | typer.echo("⚠️ No bisect session in progress. Nothing to reset.") |
| 513 | raise typer.Exit(code=ExitCode.SUCCESS) |
| 514 | |
| 515 | muse_dir = root / ".muse" |
| 516 | |
| 517 | # Restore HEAD. |
| 518 | if state.pre_bisect_ref: |
| 519 | (muse_dir / "HEAD").write_text(f"{state.pre_bisect_ref}\n") |
| 520 | logger.info("✅ bisect reset: HEAD restored to %r", state.pre_bisect_ref) |
| 521 | else: |
| 522 | logger.warning("⚠️ pre_bisect_ref missing from BISECT_STATE.json — HEAD not restored") |
| 523 | |
| 524 | # Restore muse-work/ from pre-bisect snapshot if possible. |
| 525 | if state.pre_bisect_commit: |
| 526 | async def _restore() -> int: |
| 527 | async with open_session() as session: |
| 528 | return await _checkout_snapshot_into_workdir( |
| 529 | session, root, state.pre_bisect_commit |
| 530 | ) |
| 531 | |
| 532 | try: |
| 533 | files = asyncio.run(_restore()) |
| 534 | typer.echo(f"✅ muse-work/ restored ({files} file(s)) from pre-bisect snapshot.") |
| 535 | except typer.Exit: |
| 536 | pass # Commit not found — leave muse-work/ as-is; not fatal. |
| 537 | except Exception as exc: |
| 538 | typer.echo(f"⚠️ Could not restore muse-work/: {exc}") |
| 539 | logger.warning("⚠️ bisect reset restore failed: %s", exc) |
| 540 | else: |
| 541 | typer.echo("⚠️ No pre-bisect commit recorded; muse-work/ not restored.") |
| 542 | |
| 543 | # Remove state file. |
| 544 | clear_bisect_state(root) |
| 545 | typer.echo("✅ Bisect session ended.") |
| 546 | logger.info("✅ muse bisect reset complete") |
| 547 | |
| 548 | |
| 549 | # --------------------------------------------------------------------------- |
| 550 | # log |
| 551 | # --------------------------------------------------------------------------- |
| 552 | |
| 553 | |
| 554 | @app.command("log") |
| 555 | def bisect_log( |
| 556 | json_output: bool = typer.Option( |
| 557 | False, |
| 558 | "--json", |
| 559 | help="Emit structured JSON for agent consumption.", |
| 560 | ), |
| 561 | ) -> None: |
| 562 | """Show the bisect log — verdicts recorded so far and current bounds.""" |
| 563 | root = require_repo() |
| 564 | |
| 565 | state = read_bisect_state(root) |
| 566 | if state is None: |
| 567 | typer.echo("No bisect session in progress.") |
| 568 | raise typer.Exit(code=ExitCode.SUCCESS) |
| 569 | |
| 570 | if json_output: |
| 571 | data: dict[str, object] = { |
| 572 | "good": state.good, |
| 573 | "bad": state.bad, |
| 574 | "current": state.current, |
| 575 | "tested": state.tested, |
| 576 | "pre_bisect_ref": state.pre_bisect_ref, |
| 577 | "pre_bisect_commit": state.pre_bisect_commit, |
| 578 | } |
| 579 | typer.echo(json.dumps(data, indent=2)) |
| 580 | return |
| 581 | |
| 582 | typer.echo("Bisect session state:") |
| 583 | typer.echo(f" good: {state.good or '(not set)'}") |
| 584 | typer.echo(f" bad: {state.bad or '(not set)'}") |
| 585 | typer.echo(f" current: {state.current or '(not set)'}") |
| 586 | typer.echo(f" tested ({len(state.tested)} commit(s)):") |
| 587 | for cid, verdict in sorted(state.tested.items()): |
| 588 | typer.echo(f" {cid[:8]} {verdict}") |