bisect.py python
883 lines 31.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 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 from __future__ import annotations
53
54 import argparse
55 import json
56 import logging
57 import pathlib
58 import sys
59 from typing import TypedDict
60
61 from muse.core.bisect import (
62 BisectResult,
63 _commits_touching_symbol,
64 _symbol_ops_in_commit,
65 get_bisect_log,
66 get_bisect_next,
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.errors import ExitCode
76 from muse.core.repo import read_repo_id, require_repo
77 from muse.core.store import get_head_commit_id, read_current_branch, resolve_commit_ref
78 from muse.core.validation import sanitize_display
79
80 logger = logging.getLogger(__name__)
81
82 _MAX_SYMBOL_ADDR_LEN = 500
83
84
85 # ---------------------------------------------------------------------------
86 # Typed JSON schemas
87 # ---------------------------------------------------------------------------
88
89
90 class _BisectStepJson(TypedDict):
91 """JSON output for start / bad / good / skip subcommands."""
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
102 class _BisectRunStepJson(TypedDict):
103 """One NDJSON line emitted by ``muse bisect run --json`` per step."""
104
105 step: int
106 testing: str
107 verdict: str
108 remaining_count: int
109 done: bool
110
111
112 class _BisectRunDoneJson(TypedDict):
113 """Final NDJSON line emitted by ``muse bisect run --json`` when complete."""
114
115 done: bool
116 first_bad: str | None
117 steps_taken: int
118
119
120 class _BisectLogJson(TypedDict):
121 """JSON output for ``muse bisect log --json``."""
122
123 active: bool
124 entries: list[str]
125
126
127 class _BisectResetJson(TypedDict):
128 """JSON output for ``muse bisect reset --json``."""
129
130 reset: bool
131
132
133 # ---------------------------------------------------------------------------
134 # Internal helpers
135 # ---------------------------------------------------------------------------
136
137
138 def _resolve_ref(root: pathlib.Path, ref: str | None) -> str:
139 """Resolve *ref* to a full commit ID; fall back to HEAD when *ref* is None."""
140 branch = read_current_branch(root)
141 repo_id = read_repo_id(root)
142 if ref is None:
143 commit_id = get_head_commit_id(root, branch)
144 if not commit_id:
145 print("❌ No commits on current branch.", file=sys.stderr)
146 raise SystemExit(ExitCode.USER_ERROR)
147 return commit_id
148 commit = resolve_commit_ref(root, repo_id, branch, ref)
149 if commit is None:
150 print(f"❌ Ref '{sanitize_display(ref)}' not found.", file=sys.stderr)
151 raise SystemExit(ExitCode.USER_ERROR)
152 return commit.commit_id
153
154
155 def _print_result(result: BisectResult) -> None:
156 """Render a BisectResult as human-readable text to stdout."""
157 if result.done:
158 print(f"\n✅ First bad commit found: {sanitize_display(result.first_bad or '')}")
159 print(" Run 'muse bisect reset' to end the session.")
160 else:
161 print(
162 f"Next to test: {sanitize_display(result.next_to_test or '')} "
163 f"({result.remaining_count} remaining, ~{result.steps_remaining} step(s) left)"
164 )
165 if result.symbol_changes:
166 print(" Symbol changes in this commit:")
167 for line in result.symbol_changes:
168 print(f" {sanitize_display(line)}")
169
170
171 def _result_to_json(result: BisectResult) -> _BisectStepJson:
172 """Convert a BisectResult to a typed JSON-serialisable dict."""
173 return _BisectStepJson(
174 done=result.done,
175 first_bad=result.first_bad,
176 next_to_test=result.next_to_test,
177 remaining_count=result.remaining_count,
178 steps_remaining=result.steps_remaining,
179 verdict=result.verdict,
180 symbol_changes=[sanitize_display(s) for s in result.symbol_changes],
181 )
182
183
184 # ---------------------------------------------------------------------------
185 # Subcommand handlers
186 # ---------------------------------------------------------------------------
187
188
189 def run_bisect_start(args: argparse.Namespace) -> None:
190 """Start a bisect session.
191
192 Mark the first bad and last good commits. Muse immediately suggests
193 the midpoint commit to test. Use ``--symbol`` to restrict the search to
194 commits that touched a specific symbol, dramatically reducing the number
195 of steps when the regressing symbol is already known.
196
197 All string fields in text and JSON output are sanitized — ANSI control
198 sequences are stripped.
199
200 JSON schema::
201
202 {
203 "done": true | false,
204 "first_bad": "<commit-id>" | null,
205 "next_to_test": "<commit-id>" | null,
206 "remaining_count": <int>,
207 "steps_remaining": <int>,
208 "verdict": "started",
209 "symbol_changes": ["<description>", ...]
210 }
211
212 Exit codes:
213 0 — session started (or immediately resolved when bad/good are adjacent)
214 1 — session already active, invalid --symbol format, no --good provided,
215 or ref not found
216 2 — not inside a Muse repository
217
218 Examples::
219
220 muse bisect start --bad HEAD --good v1.0.0
221 muse bisect start --bad a1b2c3 --good d4e5f6 --good g7h8i9
222 muse bisect start --bad HEAD --good v1.0.0 --symbol billing.py::Invoice.compute_total
223 muse bisect start --bad HEAD --good v1.0.0 --json
224 """
225 bad: str | None = args.bad
226 good: list[str] | None = args.good
227 symbol: str | None = args.symbol
228 output_json: bool = args.output_json
229
230 root = require_repo()
231 if is_bisect_active(root):
232 print(
233 "⚠️ A bisect session is already active. Run 'muse bisect reset' first.",
234 file=sys.stderr,
235 )
236 raise SystemExit(ExitCode.USER_ERROR)
237
238 if symbol is not None:
239 if "::" not in symbol:
240 print(
241 f"❌ --symbol must be a qualified symbol address "
242 f"(e.g. billing.py::func), got: {sanitize_display(symbol)!r}",
243 file=sys.stderr,
244 )
245 raise SystemExit(ExitCode.USER_ERROR)
246 if len(symbol) > _MAX_SYMBOL_ADDR_LEN:
247 print(
248 f"❌ --symbol address too long (max {_MAX_SYMBOL_ADDR_LEN} chars).",
249 file=sys.stderr,
250 )
251 raise SystemExit(ExitCode.USER_ERROR)
252
253 bad_id = _resolve_ref(root, bad)
254 good_ids = [_resolve_ref(root, g) for g in (good or [])]
255 if not good_ids:
256 print(
257 "❌ Provide at least one --good commit: "
258 "muse bisect start --bad HEAD --good <ref>",
259 file=sys.stderr,
260 )
261 raise SystemExit(ExitCode.USER_ERROR)
262
263 branch = read_current_branch(root)
264 result = start_bisect(root, bad_id, good_ids, branch=branch, symbol_filter=symbol or "")
265
266 if output_json:
267 print(json.dumps(_result_to_json(result)))
268 return
269
270 symbol_msg = f" symbol={sanitize_display(symbol)}" if symbol else ""
271 print(
272 f"Bisect session started. bad={bad_id[:12]} "
273 f"good=[{', '.join(g[:12] for g in good_ids)}]{symbol_msg}"
274 )
275 if symbol and result.remaining_count == 0 and not result.done:
276 print(
277 f"⚠️ No commits between bad and good touched symbol "
278 f"{sanitize_display(symbol)!r}."
279 )
280 print(" Try bisecting without --symbol, or widen the bad/good range.")
281 _print_result(result)
282
283
284 def run_bisect_bad(args: argparse.Namespace) -> None:
285 """Mark a commit as bad (the bug is present in this commit).
286
287 Narrows the bisect search range by recording that the given commit
288 (default: HEAD) exhibits the regression. Muse updates the remaining
289 set and suggests the next midpoint to test.
290
291 All string fields in text and JSON output are sanitized — ANSI control
292 sequences are stripped.
293
294 JSON schema::
295
296 {
297 "done": true | false,
298 "first_bad": "<commit-id>" | null,
299 "next_to_test": "<commit-id>" | null,
300 "remaining_count": <int>,
301 "steps_remaining": <int>,
302 "verdict": "bad",
303 "symbol_changes": ["<description>", ...]
304 }
305
306 Exit codes:
307 0 — verdict recorded; search advanced (or done=true if isolated)
308 1 — no active bisect session, or ref not found
309 2 — not inside a Muse repository
310
311 Examples::
312
313 muse bisect bad
314 muse bisect bad a1b2c3
315 muse bisect bad --json
316 muse bisect bad a1b2c3 --json
317 """
318 ref: str | None = args.ref
319 output_json: bool = args.output_json
320
321 root = require_repo()
322 if not is_bisect_active(root):
323 print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr)
324 raise SystemExit(ExitCode.USER_ERROR)
325 commit_id = _resolve_ref(root, ref)
326 result = mark_bad(root, commit_id)
327
328 if output_json:
329 print(json.dumps(_result_to_json(result)))
330 return
331
332 print(f"Marked {commit_id[:12]} as bad.")
333 _print_result(result)
334
335
336 def run_bisect_good(args: argparse.Namespace) -> None:
337 """Mark a commit as good (the bug is absent in this commit).
338
339 Narrows the bisect search range by recording that the given commit
340 (default: HEAD) does not exhibit the regression. Muse updates the
341 remaining set and suggests the next midpoint to test.
342
343 All string fields in text and JSON output are sanitized — ANSI control
344 sequences are stripped.
345
346 JSON schema::
347
348 {
349 "done": true | false,
350 "first_bad": "<commit-id>" | null,
351 "next_to_test": "<commit-id>" | null,
352 "remaining_count": <int>,
353 "steps_remaining": <int>,
354 "verdict": "good",
355 "symbol_changes": ["<description>", ...]
356 }
357
358 Exit codes:
359 0 — verdict recorded; search advanced (or done=true if isolated)
360 1 — no active bisect session, or ref not found
361 2 — not inside a Muse repository
362
363 Examples::
364
365 muse bisect good
366 muse bisect good a1b2c3
367 muse bisect good --json
368 muse bisect good a1b2c3 --json
369 """
370 ref: str | None = args.ref
371 output_json: bool = args.output_json
372
373 root = require_repo()
374 if not is_bisect_active(root):
375 print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr)
376 raise SystemExit(ExitCode.USER_ERROR)
377 commit_id = _resolve_ref(root, ref)
378 result = mark_good(root, commit_id)
379
380 if output_json:
381 print(json.dumps(_result_to_json(result)))
382 return
383
384 print(f"Marked {commit_id[:12]} as good.")
385 _print_result(result)
386
387
388 def run_bisect_skip(args: argparse.Namespace) -> None:
389 """Skip a commit that cannot be tested.
390
391 Records that the given commit (default: HEAD) is untestable — for
392 example because it fails to build. Muse excludes it from the remaining
393 set and suggests the next midpoint. In ``muse bisect run`` mode, exit
394 code 125 from the test script triggers this automatically.
395
396 All string fields in text and JSON output are sanitized — ANSI control
397 sequences are stripped.
398
399 JSON schema::
400
401 {
402 "done": true | false,
403 "first_bad": "<commit-id>" | null,
404 "next_to_test": "<commit-id>" | null,
405 "remaining_count": <int>,
406 "steps_remaining": <int>,
407 "verdict": "skip",
408 "symbol_changes": ["<description>", ...]
409 }
410
411 Exit codes:
412 0 — commit skipped; search advanced (or done=true if isolated)
413 1 — no active bisect session, or ref not found
414 2 — not inside a Muse repository
415
416 Examples::
417
418 muse bisect skip
419 muse bisect skip a1b2c3
420 muse bisect skip --json
421 muse bisect skip a1b2c3 --json
422 """
423 ref: str | None = args.ref
424 output_json: bool = args.output_json
425
426 root = require_repo()
427 if not is_bisect_active(root):
428 print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr)
429 raise SystemExit(ExitCode.USER_ERROR)
430 commit_id = _resolve_ref(root, ref)
431 result = skip_commit(root, commit_id)
432
433 if output_json:
434 print(json.dumps(_result_to_json(result)))
435 return
436
437 print(f"Skipped {commit_id[:12]}.")
438 _print_result(result)
439
440
441 def run_bisect_run(args: argparse.Namespace) -> None:
442 """Automatically bisect by running a command at each step.
443
444 The command exit code determines the verdict::
445
446 0 → good
447 125 → skip
448 else → bad
449
450 The command is run in the repository root as a shell command. Muse
451 automatically applies verdicts and advances until the first bad commit
452 is isolated.
453
454 With ``--json`` the command emits NDJSON: one step line per tested commit,
455 followed by a final summary line. All string fields in both text and JSON
456 output are sanitized — ANSI control sequences are stripped.
457
458 NDJSON step line schema (one per commit tested)::
459
460 {
461 "step": <int>,
462 "testing": "<commit-id>",
463 "verdict": "good" | "bad" | "skip",
464 "remaining_count": <int>,
465 "done": true | false
466 }
467
468 NDJSON final summary line schema::
469
470 {
471 "done": true,
472 "first_bad": "<commit-id>" | null,
473 "steps_taken": <int>
474 }
475
476 Exit codes:
477 0 — bisect run completed; first bad commit identified or session exhausted
478 1 — no active bisect session
479 2 — not inside a Muse repository
480
481 Examples::
482
483 muse bisect run "pytest tests/test_regression.py -x -q"
484 muse bisect run "make test" --json
485 muse bisect run "./check.sh" --json
486 """
487 command: str = args.command
488 output_json: bool = args.output_json
489
490 root = require_repo()
491 if not is_bisect_active(root):
492 print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr)
493 raise SystemExit(ExitCode.USER_ERROR)
494
495 step = 0
496 while True:
497 current, symbol_filter = get_bisect_next(root)
498 if current is None:
499 if output_json:
500 print(json.dumps(_BisectRunDoneJson(done=True, first_bad=None, steps_taken=step)))
501 else:
502 print("✅ Bisect complete. Run 'muse bisect reset' to end.")
503 return
504
505 if not output_json:
506 print(f" → Testing {current[:12]} …")
507 if symbol_filter:
508 changes = _symbol_ops_in_commit(root, current, symbol_filter)
509 if changes:
510 print(" Symbol changes:")
511 for line in changes:
512 print(f" {sanitize_display(line)}")
513
514 result = run_bisect_command(root, command, current)
515 step += 1
516
517 if output_json:
518 step_payload = _BisectRunStepJson(
519 step=step,
520 testing=current,
521 verdict=result.verdict,
522 remaining_count=result.remaining_count,
523 done=result.done,
524 )
525 print(json.dumps(step_payload))
526 else:
527 print(f" verdict: {result.verdict}")
528
529 if result.done:
530 if output_json:
531 print(json.dumps(
532 _BisectRunDoneJson(
533 done=True,
534 first_bad=result.first_bad,
535 steps_taken=step,
536 )
537 ))
538 else:
539 print(f"\n✅ First bad commit: {sanitize_display(result.first_bad or '')}")
540 return
541
542
543 def run_bisect_log(args: argparse.Namespace) -> None:
544 """Show the full bisect session log.
545
546 Displays every verdict applied so far, oldest first. Each log entry is
547 a space-separated triple: ``<commit-id> <verdict> <timestamp>``. Works
548 whether or not a session is currently active — returns an empty list when
549 no session has been started.
550
551 All string fields in text and JSON output are sanitized — ANSI control
552 sequences are stripped.
553
554 JSON schema::
555
556 {
557 "active": true | false,
558 "entries": ["<commit-id> <verdict> <timestamp>", ...]
559 }
560
561 Exit codes:
562 0 — always (empty entries when no session exists)
563 2 — not inside a Muse repository
564
565 Examples::
566
567 muse bisect log
568 muse bisect log --json
569 """
570 output_json: bool = args.output_json
571
572 root = require_repo()
573 entries = get_bisect_log(root)
574 active = is_bisect_active(root)
575
576 if output_json:
577 print(json.dumps(_BisectLogJson(
578 active=active,
579 entries=[sanitize_display(e) for e in entries],
580 )))
581 return
582
583 if not entries:
584 print("No bisect log. Start a session with 'muse bisect start'.")
585 return
586 print("Bisect log:")
587 for entry in entries:
588 print(f" {sanitize_display(entry)}")
589
590
591 def run_bisect_reset(args: argparse.Namespace) -> None:
592 """End the bisect session and remove all bisect state.
593
594 Idempotent — safe to call whether or not a session is currently active.
595 After reset, ``muse bisect log`` reports ``active=false`` and
596 ``muse bisect bad/good/skip`` will refuse until a new session is started.
597
598 JSON schema::
599
600 {"reset": true}
601
602 Exit codes:
603 0 — always (state removed if it existed, no-op otherwise)
604 2 — not inside a Muse repository
605
606 Examples::
607
608 muse bisect reset
609 muse bisect reset --json
610 """
611 output_json: bool = args.output_json
612
613 root = require_repo()
614 reset_bisect(root)
615
616 if output_json:
617 print(json.dumps(_BisectResetJson(reset=True)))
618 return
619
620 print("Bisect session reset.")
621
622
623 # ---------------------------------------------------------------------------
624 # Registration
625 # ---------------------------------------------------------------------------
626
627
628 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
629 """Register the ``bisect`` subcommand."""
630 parser = subparsers.add_parser(
631 "bisect",
632 help="Binary search through commit history to find regressions.",
633 description=__doc__,
634 formatter_class=argparse.RawDescriptionHelpFormatter,
635 )
636 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
637 subs.required = True
638
639 # ── bad ───────────────────────────────────────────────────────────────────
640 bad_p = subs.add_parser(
641 "bad",
642 help="Mark a commit as bad (bug present).",
643 description=(
644 "Record that the given commit (default: HEAD) exhibits the\n"
645 "regression. Muse narrows the search range and suggests the\n"
646 "next midpoint to test.\n\n"
647 "Agent quickstart\n"
648 "----------------\n"
649 " muse bisect bad --json\n"
650 " muse bisect bad <commit-id> --json\n\n"
651 "JSON output schema\n"
652 "------------------\n"
653 ' {"done": false, "first_bad": null, "next_to_test": "<commit-id>",\n'
654 ' "remaining_count": <int>, "steps_remaining": <int>,\n'
655 ' "verdict": "bad", "symbol_changes": [...]}\n\n'
656 "Exit codes\n"
657 "----------\n"
658 " 0 — verdict recorded (done=true when first bad commit isolated)\n"
659 " 1 — no active bisect session, or ref not found\n"
660 " 2 — not inside a Muse repository\n"
661 ),
662 formatter_class=argparse.RawDescriptionHelpFormatter,
663 )
664 bad_p.add_argument(
665 "ref", nargs="?", default=None, metavar="REF",
666 help="Commit to mark bad (default: HEAD).",
667 )
668 bad_p.add_argument(
669 "--json", "-j", action="store_true", dest="output_json", default=False,
670 help="Emit machine-readable JSON to stdout.",
671 )
672 bad_p.set_defaults(func=run_bisect_bad)
673
674 # ── good ──────────────────────────────────────────────────────────────────
675 good_p = subs.add_parser(
676 "good",
677 help="Mark a commit as good (bug absent).",
678 description=(
679 "Record that the given commit (default: HEAD) does not exhibit\n"
680 "the regression. Muse narrows the search range and suggests the\n"
681 "next midpoint to test.\n\n"
682 "Agent quickstart\n"
683 "----------------\n"
684 " muse bisect good --json\n"
685 " muse bisect good <commit-id> --json\n\n"
686 "JSON output schema\n"
687 "------------------\n"
688 ' {"done": false, "first_bad": null, "next_to_test": "<commit-id>",\n'
689 ' "remaining_count": <int>, "steps_remaining": <int>,\n'
690 ' "verdict": "good", "symbol_changes": [...]}\n\n'
691 "Exit codes\n"
692 "----------\n"
693 " 0 — verdict recorded (done=true when first bad commit isolated)\n"
694 " 1 — no active bisect session, or ref not found\n"
695 " 2 — not inside a Muse repository\n"
696 ),
697 formatter_class=argparse.RawDescriptionHelpFormatter,
698 )
699 good_p.add_argument(
700 "ref", nargs="?", default=None, metavar="REF",
701 help="Commit to mark good (default: HEAD).",
702 )
703 good_p.add_argument(
704 "--json", "-j", action="store_true", dest="output_json", default=False,
705 help="Emit machine-readable JSON to stdout.",
706 )
707 good_p.set_defaults(func=run_bisect_good)
708
709 # ── log ───────────────────────────────────────────────────────────────────
710 log_p = subs.add_parser(
711 "log",
712 help="Show the bisect session log.",
713 description=(
714 "Display every verdict applied in the current (or most recent)\n"
715 "bisect session, oldest first. Each entry is a space-separated\n"
716 "triple: <commit-id> <verdict> <timestamp>.\n\n"
717 "Agent quickstart\n"
718 "----------------\n"
719 " muse bisect log --json\n\n"
720 "JSON output schema\n"
721 "------------------\n"
722 ' {"active": true|false,\n'
723 ' "entries": ["<commit-id> <verdict> <timestamp>", ...]}\n\n'
724 "Exit codes\n"
725 "----------\n"
726 " 0 — always (empty entries list when no session exists)\n"
727 " 2 — not inside a Muse repository\n"
728 ),
729 formatter_class=argparse.RawDescriptionHelpFormatter,
730 )
731 log_p.add_argument(
732 "--json", "-j", action="store_true", dest="output_json", default=False,
733 help="Emit machine-readable JSON to stdout.",
734 )
735 log_p.set_defaults(func=run_bisect_log)
736
737 # ── reset ─────────────────────────────────────────────────────────────────
738 reset_p = subs.add_parser(
739 "reset",
740 help="End the bisect session and clean up state.",
741 description=(
742 "Remove all bisect state. Idempotent — safe to call whether or\n"
743 "not a session is active. After reset, bad/good/skip commands\n"
744 "will refuse until a new session is started with 'muse bisect\n"
745 "start'.\n\n"
746 "Agent quickstart\n"
747 "----------------\n"
748 " muse bisect reset --json\n\n"
749 "JSON output schema\n"
750 "------------------\n"
751 ' {"reset": true}\n\n'
752 "Exit codes\n"
753 "----------\n"
754 " 0 — always (no-op when no session is active)\n"
755 " 2 — not inside a Muse repository\n"
756 ),
757 formatter_class=argparse.RawDescriptionHelpFormatter,
758 )
759 reset_p.add_argument(
760 "--json", "-j", action="store_true", dest="output_json", default=False,
761 help="Emit machine-readable JSON to stdout.",
762 )
763 reset_p.set_defaults(func=run_bisect_reset)
764
765 # ── run ───────────────────────────────────────────────────────────────────
766 run_p = subs.add_parser(
767 "run",
768 help="Automatically bisect by running a command.",
769 description=(
770 "Run COMMAND at each bisect step; Muse interprets the exit code\n"
771 "and applies the verdict automatically until the first bad commit\n"
772 "is isolated. Exit codes: 0=good, 125=skip, anything else=bad.\n\n"
773 "Agent quickstart\n"
774 "----------------\n"
775 " muse bisect run 'pytest tests/test_regression.py -x -q' --json\n"
776 " muse bisect run './check.sh' --json\n\n"
777 "NDJSON output — one step line per commit, then a summary line\n"
778 "-----------------------------------------------------------\n"
779 ' step line: {"step":<int>, "testing":"<id>", "verdict":"good|bad|skip",\n'
780 ' "remaining_count":<int>, "done":false}\n'
781 ' done line: {"done":true, "first_bad":"<id>|null", "steps_taken":<int>}\n\n'
782 "Exit codes\n"
783 "----------\n"
784 " 0 — run complete (first bad isolated or session exhausted)\n"
785 " 1 — no active bisect session\n"
786 " 2 — not inside a Muse repository\n"
787 ),
788 formatter_class=argparse.RawDescriptionHelpFormatter,
789 )
790 run_p.add_argument(
791 "command", metavar="COMMAND",
792 help="Shell command to run at each step (exit 0=good, 125=skip, else=bad).",
793 )
794 run_p.add_argument(
795 "--json", "-j", action="store_true", dest="output_json", default=False,
796 help="Emit machine-readable NDJSON (one line per step, then a summary line).",
797 )
798 run_p.set_defaults(func=run_bisect_run)
799
800 # ── skip ──────────────────────────────────────────────────────────────────
801 skip_p = subs.add_parser(
802 "skip",
803 help="Skip an untestable commit.",
804 description=(
805 "Record that the given commit (default: HEAD) cannot be tested —\n"
806 "e.g. it fails to build. Muse excludes it from the remaining set\n"
807 "and suggests the next midpoint. In 'muse bisect run' mode, exit\n"
808 "code 125 from the test script triggers this automatically.\n\n"
809 "Agent quickstart\n"
810 "----------------\n"
811 " muse bisect skip --json\n"
812 " muse bisect skip <commit-id> --json\n\n"
813 "JSON output schema\n"
814 "------------------\n"
815 ' {"done": false, "first_bad": null, "next_to_test": "<commit-id>",\n'
816 ' "remaining_count": <int>, "steps_remaining": <int>,\n'
817 ' "verdict": "skip", "symbol_changes": [...]}\n\n'
818 "Exit codes\n"
819 "----------\n"
820 " 0 — commit skipped (done=true when first bad commit isolated)\n"
821 " 1 — no active bisect session, or ref not found\n"
822 " 2 — not inside a Muse repository\n"
823 ),
824 formatter_class=argparse.RawDescriptionHelpFormatter,
825 )
826 skip_p.add_argument(
827 "ref", nargs="?", default=None, metavar="REF",
828 help="Commit to skip (default: HEAD).",
829 )
830 skip_p.add_argument(
831 "--json", "-j", action="store_true", dest="output_json", default=False,
832 help="Emit machine-readable JSON to stdout.",
833 )
834 skip_p.set_defaults(func=run_bisect_skip)
835
836 # ── start ─────────────────────────────────────────────────────────────────
837 start_p = subs.add_parser(
838 "start",
839 help="Begin a bisect session.",
840 description=(
841 "Mark the known-bad and known-good commits, then let Muse binary-\n"
842 "search the commits between them. Muse immediately suggests the\n"
843 "midpoint commit to test. Use --symbol to restrict the search to\n"
844 "commits that touched a specific symbol.\n\n"
845 "Agent quickstart\n"
846 "----------------\n"
847 " muse bisect start --bad HEAD --good v1.0.0 --json\n"
848 " muse bisect start --bad HEAD --good v1.0.0 \\\n"
849 " --symbol billing.py::Invoice.compute_total --json\n\n"
850 "JSON output schema\n"
851 "------------------\n"
852 ' {"done": false, "first_bad": null, "next_to_test": "<commit-id>",\n'
853 ' "remaining_count": <int>, "steps_remaining": <int>,\n'
854 ' "verdict": "started", "symbol_changes": [...]}\n\n'
855 "Exit codes\n"
856 "----------\n"
857 " 0 — session started (or immediately resolved when bad/good adjacent)\n"
858 " 1 — session already active, bad --symbol, missing --good, or bad ref\n"
859 " 2 — not inside a Muse repository\n"
860 ),
861 formatter_class=argparse.RawDescriptionHelpFormatter,
862 )
863 start_p.add_argument(
864 "--bad", default=None, metavar="REF",
865 help="Known-bad commit (default: HEAD).",
866 )
867 start_p.add_argument(
868 "--good", nargs="*", default=None, metavar="REF",
869 help="Known-good commit(s). Repeat for multiple: --good v1.0 --good v0.9.",
870 )
871 start_p.add_argument(
872 "--symbol", "-s", default=None, metavar="ADDR",
873 help=(
874 "Restrict search to commits that touched this symbol "
875 "(e.g. billing.py::Invoice.compute_total). Dramatically reduces "
876 "the number of steps when you already know which symbol regressed."
877 ),
878 )
879 start_p.add_argument(
880 "--json", "-j", action="store_true", dest="output_json", default=False,
881 help="Emit machine-readable JSON to stdout.",
882 )
883 start_p.set_defaults(func=run_bisect_start)
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago