gabriel / muse public
bisect.py python
1,004 lines 37.8 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 128 days ago
1 """``muse bisect`` — binary search through commit history to find regressions.
2
3 ``muse bisect`` is Muse's power-tool for regression hunting. Given a
4 known-bad commit and a known-good commit it performs a binary search through
5 the history between them, asking at each midpoint: *"does the bug exist
6 here?"* until the first bad commit is isolated.
7
8 It is fully agent-safe: ``muse bisect run <cmd>`` automates the search by
9 running an arbitrary command at each step and interpreting the exit code:
10
11 0 → good (bug not present)
12 125 → skip (commit untestable)
13 else → bad (bug present)
14
15 Symbol-scoped bisect
16 --------------------
17 Pass ``--symbol addr`` to ``muse bisect start`` to restrict the candidate
18 commit list to only commits whose structured_delta touched that symbol.
19 This can reduce a 9-step bisect over 300 commits to a 3-step bisect over
20 the 8 commits that actually changed the symbol you care about.
21
22 muse bisect start --bad HEAD --good v1.0.0 \\
23 --symbol billing.py::Invoice.compute_total
24
25 At each step, Muse shows which ops the midpoint commit applied to the
26 symbol so you know exactly what changed before you run your tests.
27
28 JSON output
29 -----------
30 Pass ``--json`` to any subcommand for machine-readable NDJSON output.
31 The ``run`` subcommand emits one JSON object per bisect step (NDJSON),
32 plus a final summary line. All other subcommands emit a single JSON
33 object on stdout.
34
35 Subcommands::
36
37 muse bisect start [--bad <ref>] [--good <ref>] [--symbol <addr>] [--json]
38 muse bisect bad [<ref>] [--json]
39 muse bisect good [<ref>] [--json]
40 muse bisect skip [<ref>] [--json]
41 muse bisect run <command> [--json]
42 muse bisect log [--json]
43 muse bisect reset [--json]
44
45 Exit codes::
46
47 0 — success
48 1 — user error (no session, bad ref, bad args)
49 2 — internal error (lost state)
50 """
51
52 import argparse
53 import json
54 import logging
55 import pathlib
56 import sys
57 from typing import TypedDict
58
59 from muse.core.bisect import (
60 BisectResult,
61 BisectStatusDict,
62 _commits_touching_symbol,
63 _symbol_ops_in_commit,
64 get_bisect_log,
65 get_bisect_next,
66 get_bisect_status,
67 is_bisect_active,
68 mark_bad,
69 mark_good,
70 reset_bisect,
71 run_bisect_command,
72 skip_commit,
73 start_bisect,
74 )
75 from muse.core.envelope import EnvelopeJson, make_envelope
76 from muse.core.timing import start_timer
77 from muse.core.errors import ExitCode
78 from muse.core.repo import read_repo_id, require_repo
79 from muse.core.store import get_head_commit_id, read_current_branch, resolve_commit_ref
80 from muse.core.validation import sanitize_display
81
82 logger = logging.getLogger(__name__)
83
84 _MAX_SYMBOL_ADDR_LEN = 500
85
86 # ---------------------------------------------------------------------------
87 # Typed JSON schemas
88 # ---------------------------------------------------------------------------
89
90 class _BisectStepPayload(TypedDict):
91 """Domain fields built by ``_result_to_json``."""
92
93 done: bool
94 first_bad: str | None
95 next_to_test: str | None
96 remaining_count: int
97 steps_remaining: int
98 verdict: str
99 symbol_changes: list[str]
100
101 class _BisectStepJson(_BisectStepPayload, EnvelopeJson):
102 """Full wire shape for start / bad / good / skip subcommands."""
103
104 class _BisectLogEntryJson(TypedDict):
105 """One structured entry in the bisect log."""
106
107 commit_id: str
108 verdict: str
109 timestamp: str
110
111 class _BisectStatusJson(EnvelopeJson, total=False):
112 """JSON output for ``muse bisect status --json``.
113
114 ``active`` is always present. All other keys are only present when
115 ``active`` is ``True``.
116 """
117
118 active: bool
119 bad_id: str
120 good_ids: list[str]
121 symbol_filter: str
122 remaining_count: int
123 steps_remaining: int
124 skipped_count: int
125 branch: str
126
127 class _BisectRunStepJson(EnvelopeJson):
128 """One NDJSON line emitted by ``muse bisect run --json`` per step."""
129
130 step: int
131 testing: str
132 verdict: str
133 remaining_count: int
134 done: bool
135 symbol_changes: list[str]
136
137 class _BisectRunDoneJson(TypedDict):
138 """Final NDJSON line emitted by ``muse bisect run --json`` when complete."""
139
140 done: bool
141 first_bad: str | None
142 steps_taken: int
143
144 class _BisectLogJson(EnvelopeJson):
145 """JSON output for ``muse bisect log --json``."""
146
147 active: bool
148 entries: list[_BisectLogEntryJson]
149
150 class _BisectResetJson(EnvelopeJson):
151 """JSON output for ``muse bisect reset --json``."""
152
153 reset: bool
154
155 # ---------------------------------------------------------------------------
156 # Internal helpers
157 # ---------------------------------------------------------------------------
158
159 def _resolve_ref(root: pathlib.Path, ref: str | None) -> str:
160 """Resolve *ref* to a full commit ID; fall back to HEAD when *ref* is None."""
161 branch = read_current_branch(root)
162 repo_id = read_repo_id(root)
163 if ref is None:
164 commit_id = get_head_commit_id(root, branch)
165 if not commit_id:
166 print("❌ No commits on current branch.", file=sys.stderr)
167 raise SystemExit(ExitCode.USER_ERROR)
168 return commit_id
169 commit = resolve_commit_ref(root, repo_id, branch, ref)
170 if commit is None:
171 print(f"❌ Ref '{sanitize_display(ref)}' not found.", file=sys.stderr)
172 raise SystemExit(ExitCode.USER_ERROR)
173 return commit.commit_id
174
175 def _print_result(result: BisectResult) -> None:
176 """Render a BisectResult as human-readable text to stdout."""
177 if result.done:
178 print(f"\n✅ First bad commit found: {sanitize_display(result.first_bad or '')}")
179 print(" Run 'muse bisect reset' to end the session.")
180 else:
181 print(
182 f"Next to test: {sanitize_display(result.next_to_test or '')} "
183 f"({result.remaining_count} remaining, ~{result.steps_remaining} step(s) left)"
184 )
185 if result.symbol_changes:
186 print(" Symbol changes in this commit:")
187 for line in result.symbol_changes:
188 print(f" {sanitize_display(line)}")
189
190 def _result_to_json(result: BisectResult) -> _BisectStepPayload:
191 """Convert a BisectResult to a typed JSON-serialisable dict."""
192 return _BisectStepPayload(
193 done=result.done,
194 first_bad=result.first_bad,
195 next_to_test=result.next_to_test,
196 remaining_count=result.remaining_count,
197 steps_remaining=result.steps_remaining,
198 verdict=result.verdict,
199 symbol_changes=[sanitize_display(s) for s in result.symbol_changes],
200 )
201
202 def _parse_log_entry(raw: str) -> _BisectLogEntryJson:
203 """Parse ``"<commit_id> <verdict> <ISO8601_timestamp>"`` into a typed dict.
204
205 The on-disk log format written by :func:`muse.core.bisect._apply_verdict`
206 and :func:`muse.core.bisect.start_bisect` is a space-separated triple.
207 Any missing or malformed parts default to empty strings. All fields are
208 sanitized before output.
209 """
210 parts = raw.split(" ", 2)
211 return _BisectLogEntryJson(
212 commit_id=sanitize_display(parts[0]) if len(parts) > 0 else "",
213 verdict=sanitize_display(parts[1]) if len(parts) > 1 else "",
214 timestamp=sanitize_display(parts[2]) if len(parts) > 2 else "",
215 )
216
217 # ---------------------------------------------------------------------------
218 # Subcommand handlers
219 # ---------------------------------------------------------------------------
220
221 def run_bisect_start(args: argparse.Namespace) -> None:
222 """Start a bisect session between a known-bad and known-good commit.
223
224 Immediately suggests the midpoint commit to test. Use ``--symbol`` to
225 restrict the search to commits that touched a specific symbol — this can
226 reduce a 9-step bisect to 3 steps when the regressing symbol is known.
227
228 Agent quickstart
229 ----------------
230 ::
231
232 muse bisect start --bad HEAD --good v1.0.0 --json
233 muse bisect start --bad HEAD --good v1.0.0 --symbol billing.py::Invoice.compute_total --json
234
235 JSON fields
236 -----------
237 done ``true`` when the first bad commit has been isolated.
238 first_bad Full commit ID of the first bad commit; ``null`` while in progress.
239 next_to_test Full commit ID of the next midpoint to test; ``null`` when done.
240 remaining_count Number of commits still in the candidate set.
241 steps_remaining Estimated binary-search steps left.
242 verdict ``"started"`` for this subcommand.
243 symbol_changes List of symbol-op descriptions for the midpoint commit.
244
245 Exit codes
246 ----------
247 0 Session started (or immediately resolved when bad/good are adjacent).
248 1 Session already active, invalid ``--symbol``, no ``--good``, or ref not found.
249 2 Not inside a Muse repository.
250 """
251 elapsed = start_timer()
252 bad: str | None = args.bad
253 good: list[str] | None = args.good
254 symbol: str | None = args.symbol
255 json_out: bool = args.json_out
256
257 root = require_repo()
258 if is_bisect_active(root):
259 print(
260 "⚠️ A bisect session is already active. Run 'muse bisect reset' first.",
261 file=sys.stderr,
262 )
263 raise SystemExit(ExitCode.USER_ERROR)
264
265 if symbol is not None:
266 if "::" not in symbol:
267 print(
268 f"❌ --symbol must be a qualified symbol address "
269 f"(e.g. billing.py::func), got: {sanitize_display(symbol)!r}",
270 file=sys.stderr,
271 )
272 raise SystemExit(ExitCode.USER_ERROR)
273 if len(symbol) > _MAX_SYMBOL_ADDR_LEN:
274 print(
275 f"❌ --symbol address too long (max {_MAX_SYMBOL_ADDR_LEN} chars).",
276 file=sys.stderr,
277 )
278 raise SystemExit(ExitCode.USER_ERROR)
279
280 bad_id = _resolve_ref(root, bad)
281 good_ids = [_resolve_ref(root, g) for g in (good or [])]
282 if not good_ids:
283 print(
284 "❌ Provide at least one --good commit: "
285 "muse bisect start --bad HEAD --good <ref>",
286 file=sys.stderr,
287 )
288 raise SystemExit(ExitCode.USER_ERROR)
289
290 branch = read_current_branch(root)
291 result = start_bisect(root, bad_id, good_ids, branch=branch, symbol_filter=symbol or "")
292
293 if json_out:
294 print(json.dumps(_BisectStepJson(**make_envelope(elapsed), **_result_to_json(result))))
295 return
296
297 symbol_msg = f" symbol={sanitize_display(symbol)}" if symbol else ""
298 print(
299 f"Bisect session started. bad={bad_id} "
300 f"good=[{', '.join(good_ids)}]{symbol_msg}"
301 )
302 if symbol and result.remaining_count == 0 and not result.done:
303 print(
304 f"⚠️ No commits between bad and good touched symbol "
305 f"{sanitize_display(symbol)!r}."
306 )
307 print(" Try bisecting without --symbol, or widen the bad/good range.")
308 _print_result(result)
309
310 def run_bisect_bad(args: argparse.Namespace) -> None:
311 """Mark a commit as bad (the bug is present in this commit).
312
313 Narrows the bisect search range by recording that the given commit
314 (default: HEAD) exhibits the regression. Muse updates the remaining
315 set and suggests the next midpoint to test.
316
317 Agent quickstart
318 ----------------
319 ::
320
321 muse bisect bad --json
322 muse bisect bad a1b2c3 --json
323
324 JSON fields
325 -----------
326 done ``true`` when the first bad commit has been isolated.
327 first_bad Full commit ID of the first bad commit; ``null`` while in progress.
328 next_to_test Full commit ID of the next midpoint to test; ``null`` when done.
329 remaining_count Commits still in the candidate set.
330 steps_remaining Estimated binary-search steps left.
331 verdict ``"bad"`` for this subcommand.
332 symbol_changes List of symbol-op descriptions for the midpoint commit.
333
334 Exit codes
335 ----------
336 0 Verdict recorded; search advanced (or ``done=true`` if isolated).
337 1 No active bisect session, or ref not found.
338 2 Not inside a Muse repository.
339 """
340 elapsed = start_timer()
341 ref: str | None = args.ref
342 json_out: bool = args.json_out
343
344 root = require_repo()
345 if not is_bisect_active(root):
346 print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr)
347 raise SystemExit(ExitCode.USER_ERROR)
348 commit_id = _resolve_ref(root, ref)
349 result = mark_bad(root, commit_id)
350
351 if json_out:
352 print(json.dumps(_BisectStepJson(**make_envelope(elapsed), **_result_to_json(result))))
353 return
354
355 print(f"Marked {commit_id} as bad.")
356 _print_result(result)
357
358 def run_bisect_good(args: argparse.Namespace) -> None:
359 """Mark a commit as good (the bug is absent in this commit).
360
361 Narrows the bisect search range — Muse updates the candidate set and
362 suggests the next midpoint to test.
363
364 Agent quickstart
365 ----------------
366 ::
367
368 muse bisect good --json
369 muse bisect good a1b2c3 --json
370
371 JSON fields
372 -----------
373 done ``true`` when the first bad commit has been isolated.
374 first_bad Full commit ID of the first bad commit; ``null`` while in progress.
375 next_to_test Full commit ID of the next midpoint to test; ``null`` when done.
376 remaining_count Commits still in the candidate set.
377 steps_remaining Estimated binary-search steps left.
378 verdict ``"good"`` for this subcommand.
379 symbol_changes List of symbol-op descriptions for the midpoint commit.
380
381 Exit codes
382 ----------
383 0 Verdict recorded; search advanced (or ``done=true`` if isolated).
384 1 No active bisect session, or ref not found.
385 2 Not inside a Muse repository.
386 """
387 elapsed = start_timer()
388 ref: str | None = args.ref
389 json_out: bool = args.json_out
390
391 root = require_repo()
392 if not is_bisect_active(root):
393 print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr)
394 raise SystemExit(ExitCode.USER_ERROR)
395 commit_id = _resolve_ref(root, ref)
396 result = mark_good(root, commit_id)
397
398 if json_out:
399 print(json.dumps(_BisectStepJson(**make_envelope(elapsed), **_result_to_json(result))))
400 return
401
402 print(f"Marked {commit_id} as good.")
403 _print_result(result)
404
405 def run_bisect_skip(args: argparse.Namespace) -> None:
406 """Skip a commit that cannot be tested (e.g. fails to build).
407
408 Muse excludes it from the remaining set and suggests the next midpoint.
409 In ``muse bisect run`` mode, exit code 125 from the test script triggers
410 this automatically.
411
412 Agent quickstart
413 ----------------
414 ::
415
416 muse bisect skip --json
417 muse bisect skip a1b2c3 --json
418
419 JSON fields
420 -----------
421 done ``true`` when the first bad commit has been isolated.
422 first_bad Full commit ID of the first bad commit; ``null`` while in progress.
423 next_to_test Full commit ID of the next midpoint to test; ``null`` when done.
424 remaining_count Commits still in the candidate set.
425 steps_remaining Estimated binary-search steps left.
426 verdict ``"skip"`` for this subcommand.
427 symbol_changes List of symbol-op descriptions for the midpoint commit.
428
429 Exit codes
430 ----------
431 0 Commit skipped; search advanced (or ``done=true`` if isolated).
432 1 No active bisect session, or ref not found.
433 2 Not inside a Muse repository.
434 """
435 elapsed = start_timer()
436 ref: str | None = args.ref
437 json_out: bool = args.json_out
438
439 root = require_repo()
440 if not is_bisect_active(root):
441 print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr)
442 raise SystemExit(ExitCode.USER_ERROR)
443 commit_id = _resolve_ref(root, ref)
444 result = skip_commit(root, commit_id)
445
446 if json_out:
447 print(json.dumps(_BisectStepJson(**make_envelope(elapsed), **_result_to_json(result))))
448 return
449
450 print(f"Skipped {commit_id}.")
451 _print_result(result)
452
453 def run_bisect_run(args: argparse.Namespace) -> None:
454 """Automatically bisect by running a command at each step.
455
456 The command exit code determines the verdict::
457
458 0 → good
459 125 → skip
460 else → bad
461
462 The command is run in the repository root. Exit code ``0`` → good,
463 ``125`` → skip, anything else → bad.
464
465 Agent quickstart
466 ----------------
467 ::
468
469 muse bisect run "pytest tests/test_regression.py -x -q" --json
470 muse bisect run "./check.sh" --json
471
472 JSON fields (NDJSON — one object per step, then a final summary)
473 ----------------------------------------------------------------
474 Per-step fields:
475
476 step Step number (1-based).
477 testing Full commit ID being tested.
478 verdict ``"good"``, ``"bad"``, or ``"skip"``.
479 remaining_count Candidates remaining after this step.
480 done ``true`` on the final step.
481 symbol_changes Symbol-op descriptions for the commit tested.
482
483 Final summary fields:
484
485 done Always ``true``.
486 first_bad Full commit ID of the first bad commit; ``null`` if none isolated.
487 steps_taken Total number of steps executed.
488
489 Exit codes
490 ----------
491 0 Bisect run completed; first bad commit identified or session exhausted.
492 1 No active bisect session.
493 2 Not inside a Muse repository.
494 """
495 elapsed = start_timer()
496 command: str = args.command
497 json_out: bool = args.json_out
498 timeout: int | None = args.timeout
499
500 root = require_repo()
501 if not is_bisect_active(root):
502 print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr)
503 raise SystemExit(ExitCode.USER_ERROR)
504
505 step = 0
506 while True:
507 current, symbol_filter = get_bisect_next(root)
508 if current is None:
509 if json_out:
510 # NDJSON — keep compact (one line per record)
511 print(json.dumps(_BisectRunDoneJson(done=True, first_bad=None, steps_taken=step)))
512 else:
513 print("✅ Bisect complete. Run 'muse bisect reset' to end.")
514 return
515
516 # Collect symbol changes before running the command (they describe the
517 # commit we are about to test, not the result of the test).
518 changes: list[str] = []
519 if symbol_filter:
520 changes = _symbol_ops_in_commit(root, current, symbol_filter)
521
522 if not json_out:
523 print(f" → Testing {current} …")
524 if changes:
525 print(" Symbol changes:")
526 for line in changes:
527 print(f" {sanitize_display(line)}")
528
529 result = run_bisect_command(root, command, current, timeout=timeout)
530 step += 1
531
532 if json_out:
533 # NDJSON — keep compact (one line per step record)
534 print(json.dumps(_BisectRunStepJson(
535 **make_envelope(elapsed),
536 step=step,
537 testing=current,
538 verdict=result.verdict,
539 remaining_count=result.remaining_count,
540 done=result.done,
541 symbol_changes=[sanitize_display(s) for s in changes],
542 )))
543 else:
544 print(f" verdict: {result.verdict}")
545
546 if result.done:
547 if json_out:
548 # NDJSON — keep compact
549 print(json.dumps(
550 _BisectRunDoneJson(
551 done=True,
552 first_bad=result.first_bad,
553 steps_taken=step,
554 )
555 ))
556 else:
557 print(f"\n✅ First bad commit: {sanitize_display(result.first_bad or '')}")
558 return
559
560 def run_bisect_log(args: argparse.Namespace) -> None:
561 """Show the full bisect session log.
562
563 Displays every verdict applied so far (oldest first). Works whether or
564 not a session is currently active — returns an empty list when no session
565 has been started.
566
567 Agent quickstart
568 ----------------
569 ::
570
571 muse bisect log --json
572
573 JSON fields
574 -----------
575 active ``true`` when a bisect session is in progress.
576 entries List of log entry objects (oldest first).
577
578 Each entry:
579
580 commit_id Commit ID string.
581 verdict ``"bad"``, ``"good"``, or ``"skip"``.
582 timestamp ISO-8601 timestamp of the verdict.
583
584 Exit codes
585 ----------
586 0 Always (empty entries when no session exists).
587 2 Not inside a Muse repository.
588 """
589 elapsed = start_timer()
590 json_out: bool = args.json_out
591
592 root = require_repo()
593 entries = get_bisect_log(root)
594 active = is_bisect_active(root)
595
596 if json_out:
597 print(json.dumps(_BisectLogJson(**make_envelope(elapsed), active=active, entries=[_parse_log_entry(e) for e in entries])))
598 return
599
600 if not entries:
601 print("No bisect log. Start a session with 'muse bisect start'.")
602 return
603 print("Bisect log:")
604 for entry in entries:
605 print(f" {sanitize_display(entry)}")
606
607 def run_bisect_reset(args: argparse.Namespace) -> None:
608 """End the bisect session and remove all bisect state.
609
610 Idempotent — safe to call whether or not a session is active.
611
612 Agent quickstart
613 ----------------
614 ::
615
616 muse bisect reset --json
617
618 JSON fields
619 -----------
620 reset Always ``true``.
621
622 Exit codes
623 ----------
624 0 Always (state removed if it existed, no-op otherwise).
625 2 Not inside a Muse repository.
626 """
627 elapsed = start_timer()
628 json_out: bool = args.json_out
629
630 root = require_repo()
631 reset_bisect(root)
632
633 if json_out:
634 print(json.dumps(_BisectResetJson(**make_envelope(elapsed), reset=True)))
635 return
636
637 print("Bisect session reset.")
638
639 def run_bisect_status(args: argparse.Namespace) -> None:
640 """Report the current bisect session state without modifying anything.
641
642 Read-only snapshot of the active session. Call this at the start of any
643 bisect workflow to discover whether a session is already running.
644
645 Agent quickstart
646 ----------------
647 ::
648
649 muse bisect status --json
650
651 JSON fields
652 -----------
653 active ``true`` when a bisect session is in progress; ``false`` otherwise.
654
655 When ``active`` is ``true``:
656
657 bad_id Full commit ID of the known-bad commit.
658 good_ids List of known-good commit IDs.
659 symbol_filter Symbol address filter; empty string if none.
660 remaining_count Commits still in the candidate set.
661 steps_remaining Estimated binary-search steps left.
662 skipped_count Number of commits skipped so far.
663 branch Branch the session was started on.
664
665 Exit codes
666 ----------
667 0 Always (``active=false`` when no session exists).
668 2 Not inside a Muse repository.
669 """
670 elapsed = start_timer()
671 json_out: bool = args.json_out
672
673 root = require_repo()
674 status = get_bisect_status(root)
675
676 if json_out:
677 print(json.dumps(_BisectStatusJson(**make_envelope(elapsed), **dict(status))))
678 return
679
680 if not status.get("active"):
681 print("No bisect session active. Start one with 'muse bisect start'.")
682 return
683
684 print("Bisect session active.")
685 bad = status.get("bad_id", "")
686 print(f" bad: {sanitize_display(bad)}")
687 good_ids = status.get("good_ids", [])
688 good_display = ", ".join(sanitize_display(g) for g in good_ids)
689 print(f" good: {good_display}")
690 sym = status.get("symbol_filter", "")
691 if sym:
692 print(f" symbol: {sanitize_display(sym)}")
693 remaining = status.get("remaining_count", 0)
694 steps = status.get("steps_remaining", 0)
695 print(f" remaining: {remaining} commit(s) (~{steps} step(s))")
696 print(f" skipped: {status.get('skipped_count', 0)}")
697 branch = status.get("branch", "")
698 if branch:
699 print(f" branch: {sanitize_display(branch)}")
700
701 # ---------------------------------------------------------------------------
702 # Registration
703 # ---------------------------------------------------------------------------
704
705 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
706 """Register the ``bisect`` subcommand."""
707 parser = subparsers.add_parser(
708 "bisect",
709 help="Binary search through commit history to find regressions.",
710 description=__doc__,
711 formatter_class=argparse.RawDescriptionHelpFormatter,
712 )
713 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
714 subs.required = True
715
716 # ── bad ───────────────────────────────────────────────────────────────────
717 bad_p = subs.add_parser(
718 "bad",
719 help="Mark a commit as bad (bug present).",
720 description=(
721 "Record that the given commit (default: HEAD) exhibits the\n"
722 "regression. Muse narrows the search range and suggests the\n"
723 "next midpoint to test.\n\n"
724 "Agent quickstart\n"
725 "----------------\n"
726 " muse bisect bad --json\n"
727 " muse bisect bad <commit-id> --json\n\n"
728 "JSON output schema\n"
729 "------------------\n"
730 ' {"done": false, "first_bad": null, "next_to_test": "<commit-id>",\n'
731 ' "remaining_count": <int>, "steps_remaining": <int>,\n'
732 ' "verdict": "bad", "symbol_changes": [...]}\n\n'
733 "Exit codes\n"
734 "----------\n"
735 " 0 — verdict recorded (done=true when first bad commit isolated)\n"
736 " 1 — no active bisect session, or ref not found\n"
737 " 2 — not inside a Muse repository\n"
738 ),
739 formatter_class=argparse.RawDescriptionHelpFormatter,
740 )
741 bad_p.add_argument(
742 "ref", nargs="?", default=None, metavar="REF",
743 help="Commit to mark bad (default: HEAD).",
744 )
745 bad_p.add_argument(
746 "--json", "-j", action="store_true", dest="json_out", default=False,
747 help="Emit machine-readable JSON to stdout.",
748 )
749 bad_p.set_defaults(func=run_bisect_bad)
750
751 # ── good ──────────────────────────────────────────────────────────────────
752 good_p = subs.add_parser(
753 "good",
754 help="Mark a commit as good (bug absent).",
755 description=(
756 "Record that the given commit (default: HEAD) does not exhibit\n"
757 "the regression. Muse narrows the search range and suggests the\n"
758 "next midpoint to test.\n\n"
759 "Agent quickstart\n"
760 "----------------\n"
761 " muse bisect good --json\n"
762 " muse bisect good <commit-id> --json\n\n"
763 "JSON output schema\n"
764 "------------------\n"
765 ' {"done": false, "first_bad": null, "next_to_test": "<commit-id>",\n'
766 ' "remaining_count": <int>, "steps_remaining": <int>,\n'
767 ' "verdict": "good", "symbol_changes": [...]}\n\n'
768 "Exit codes\n"
769 "----------\n"
770 " 0 — verdict recorded (done=true when first bad commit isolated)\n"
771 " 1 — no active bisect session, or ref not found\n"
772 " 2 — not inside a Muse repository\n"
773 ),
774 formatter_class=argparse.RawDescriptionHelpFormatter,
775 )
776 good_p.add_argument(
777 "ref", nargs="?", default=None, metavar="REF",
778 help="Commit to mark good (default: HEAD).",
779 )
780 good_p.add_argument(
781 "--json", "-j", action="store_true", dest="json_out", default=False,
782 help="Emit machine-readable JSON to stdout.",
783 )
784 good_p.set_defaults(func=run_bisect_good)
785
786 # ── log ───────────────────────────────────────────────────────────────────
787 log_p = subs.add_parser(
788 "log",
789 help="Show the bisect session log.",
790 description=(
791 "Display every verdict applied in the current (or most recent)\n"
792 "bisect session, oldest first. Each entry is a space-separated\n"
793 "triple: <commit-id> <verdict> <timestamp>.\n\n"
794 "Agent quickstart\n"
795 "----------------\n"
796 " muse bisect log --json\n\n"
797 "JSON output schema\n"
798 "------------------\n"
799 ' {"active": true|false,\n'
800 ' "entries": [{"commit_id": "<id>", "verdict": "bad|good|skip",\n'
801 ' "timestamp": "<ISO8601>"}, ...]}\n\n'
802 "Exit codes\n"
803 "----------\n"
804 " 0 — always (empty entries list when no session exists)\n"
805 " 2 — not inside a Muse repository\n"
806 ),
807 formatter_class=argparse.RawDescriptionHelpFormatter,
808 )
809 log_p.add_argument(
810 "--json", "-j", action="store_true", dest="json_out", default=False,
811 help="Emit machine-readable JSON to stdout.",
812 )
813 log_p.set_defaults(func=run_bisect_log)
814
815 # ── reset ─────────────────────────────────────────────────────────────────
816 reset_p = subs.add_parser(
817 "reset",
818 help="End the bisect session and clean up state.",
819 description=(
820 "Remove all bisect state. Idempotent — safe to call whether or\n"
821 "not a session is active. After reset, bad/good/skip commands\n"
822 "will refuse until a new session is started with 'muse bisect\n"
823 "start'.\n\n"
824 "Agent quickstart\n"
825 "----------------\n"
826 " muse bisect reset --json\n\n"
827 "JSON output schema\n"
828 "------------------\n"
829 ' {"reset": true}\n\n'
830 "Exit codes\n"
831 "----------\n"
832 " 0 — always (no-op when no session is active)\n"
833 " 2 — not inside a Muse repository\n"
834 ),
835 formatter_class=argparse.RawDescriptionHelpFormatter,
836 )
837 reset_p.add_argument(
838 "--json", "-j", action="store_true", dest="json_out", default=False,
839 help="Emit machine-readable JSON to stdout.",
840 )
841 reset_p.set_defaults(func=run_bisect_reset)
842
843 # ── run ───────────────────────────────────────────────────────────────────
844 run_p = subs.add_parser(
845 "run",
846 help="Automatically bisect by running a command.",
847 description=(
848 "Run COMMAND at each bisect step; Muse interprets the exit code\n"
849 "and applies the verdict automatically until the first bad commit\n"
850 "is isolated. Exit codes: 0=good, 125=skip, anything else=bad.\n\n"
851 "Agent quickstart\n"
852 "----------------\n"
853 " muse bisect run 'pytest tests/test_regression.py -x -q' --json\n"
854 " muse bisect run './check.sh' --json\n\n"
855 "NDJSON output — one step line per commit, then a summary line\n"
856 "-----------------------------------------------------------\n"
857 ' step line: {"step":<int>, "testing":"<id>", "verdict":"good|bad|skip",\n'
858 ' "remaining_count":<int>, "done":false,\n'
859 ' "symbol_changes":[...]}\n'
860 ' done line: {"done":true, "first_bad":"<id>|null", "steps_taken":<int>}\n\n'
861 "Exit codes\n"
862 "----------\n"
863 " 0 — run complete (first bad isolated or session exhausted)\n"
864 " 1 — no active bisect session\n"
865 " 2 — not inside a Muse repository\n"
866 ),
867 formatter_class=argparse.RawDescriptionHelpFormatter,
868 )
869 run_p.add_argument(
870 "command", metavar="COMMAND",
871 help="Shell command to run at each step (exit 0=good, 125=skip, else=bad).",
872 )
873 run_p.add_argument(
874 "--json", "-j", action="store_true", dest="json_out", default=False,
875 help="Emit machine-readable NDJSON (one line per step, then a summary line).",
876 )
877 run_p.add_argument(
878 "--timeout", "-t", type=int, default=None, metavar="SECONDS",
879 help=(
880 "Kill the test command after SECONDS and treat the commit as "
881 "untestable (skip, same as exit 125). Prevents a hanging command "
882 "from stalling an automated bisect."
883 ),
884 )
885 run_p.set_defaults(func=run_bisect_run)
886
887 # ── skip ──────────────────────────────────────────────────────────────────
888 skip_p = subs.add_parser(
889 "skip",
890 help="Skip an untestable commit.",
891 description=(
892 "Record that the given commit (default: HEAD) cannot be tested —\n"
893 "e.g. it fails to build. Muse excludes it from the remaining set\n"
894 "and suggests the next midpoint. In 'muse bisect run' mode, exit\n"
895 "code 125 from the test script triggers this automatically.\n\n"
896 "Agent quickstart\n"
897 "----------------\n"
898 " muse bisect skip --json\n"
899 " muse bisect skip <commit-id> --json\n\n"
900 "JSON output schema\n"
901 "------------------\n"
902 ' {"done": false, "first_bad": null, "next_to_test": "<commit-id>",\n'
903 ' "remaining_count": <int>, "steps_remaining": <int>,\n'
904 ' "verdict": "skip", "symbol_changes": [...]}\n\n'
905 "Exit codes\n"
906 "----------\n"
907 " 0 — commit skipped (done=true when first bad commit isolated)\n"
908 " 1 — no active bisect session, or ref not found\n"
909 " 2 — not inside a Muse repository\n"
910 ),
911 formatter_class=argparse.RawDescriptionHelpFormatter,
912 )
913 skip_p.add_argument(
914 "ref", nargs="?", default=None, metavar="REF",
915 help="Commit to skip (default: HEAD).",
916 )
917 skip_p.add_argument(
918 "--json", "-j", action="store_true", dest="json_out", default=False,
919 help="Emit machine-readable JSON to stdout.",
920 )
921 skip_p.set_defaults(func=run_bisect_skip)
922
923 # ── start ─────────────────────────────────────────────────────────────────
924 start_p = subs.add_parser(
925 "start",
926 help="Begin a bisect session.",
927 description=(
928 "Mark the known-bad and known-good commits, then let Muse binary-\n"
929 "search the commits between them. Muse immediately suggests the\n"
930 "midpoint commit to test. Use --symbol to restrict the search to\n"
931 "commits that touched a specific symbol.\n\n"
932 "Agent quickstart\n"
933 "----------------\n"
934 " muse bisect start --bad HEAD --good v1.0.0 --json\n"
935 " muse bisect start --bad HEAD --good v1.0.0 \\\n"
936 " --symbol billing.py::Invoice.compute_total --json\n\n"
937 "JSON output schema\n"
938 "------------------\n"
939 ' {"done": false, "first_bad": null, "next_to_test": "<commit-id>",\n'
940 ' "remaining_count": <int>, "steps_remaining": <int>,\n'
941 ' "verdict": "started", "symbol_changes": [...]}\n\n'
942 "Exit codes\n"
943 "----------\n"
944 " 0 — session started (or immediately resolved when bad/good adjacent)\n"
945 " 1 — session already active, bad --symbol, missing --good, or bad ref\n"
946 " 2 — not inside a Muse repository\n"
947 ),
948 formatter_class=argparse.RawDescriptionHelpFormatter,
949 )
950 start_p.add_argument(
951 "--bad", default=None, metavar="REF",
952 help="Known-bad commit (default: HEAD).",
953 )
954 start_p.add_argument(
955 "--good", nargs="*", default=None, metavar="REF",
956 help="Known-good commit(s). Repeat for multiple: --good v1.0 --good v0.9.",
957 )
958 start_p.add_argument(
959 "--symbol", "-s", default=None, metavar="ADDR",
960 help=(
961 "Restrict search to commits that touched this symbol "
962 "(e.g. billing.py::Invoice.compute_total). Dramatically reduces "
963 "the number of steps when you already know which symbol regressed."
964 ),
965 )
966 start_p.add_argument(
967 "--json", "-j", action="store_true", dest="json_out", default=False,
968 help="Emit machine-readable JSON to stdout.",
969 )
970 start_p.set_defaults(func=run_bisect_start)
971
972 # ── status ────────────────────────────────────────────────────────────────
973 status_p = subs.add_parser(
974 "status",
975 help="Report the current bisect session state (read-only).",
976 description=(
977 "Return a structured read-only snapshot of the active bisect\n"
978 "session. Safe to call at any time — never modifies state.\n"
979 "Agents should use this instead of parsing 'muse bisect log'\n"
980 "to discover whether a session is running.\n\n"
981 "Agent quickstart\n"
982 "----------------\n"
983 " muse bisect status --json\n\n"
984 "JSON output schema (active)\n"
985 "---------------------------\n"
986 ' {"active": true, "bad_id": "<id>", "good_ids": ["<id>", ...],\n'
987 ' "symbol_filter": "", "remaining_count": <int>,\n'
988 ' "steps_remaining": <int>, "skipped_count": <int>,\n'
989 ' "branch": "<branch>"}\n\n'
990 "JSON output schema (no session)\n"
991 "-------------------------------\n"
992 ' {"active": false}\n\n'
993 "Exit codes\n"
994 "----------\n"
995 " 0 — always (active=false when no session exists)\n"
996 " 2 — not inside a Muse repository\n"
997 ),
998 formatter_class=argparse.RawDescriptionHelpFormatter,
999 )
1000 status_p.add_argument(
1001 "--json", "-j", action="store_true", dest="json_out", default=False,
1002 help="Emit machine-readable JSON to stdout.",
1003 )
1004 status_p.set_defaults(func=run_bisect_status)
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 128 days ago