cgcardona / muse public
bisect.py python
588 lines 20.8 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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}")