gabriel / muse public
bisect.py python
236 lines 7.9 KB
f7645c07 feat(store): self-describing HEAD format with typed read/write API (#163) Gabriel Cardona <cgcardona@gmail.com> 3d 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 Subcommands::
16
17 muse bisect start [--bad <ref>] [--good <ref>] … — begin session
18 muse bisect bad [<ref>] — mark current/ref as bad
19 muse bisect good [<ref>] — mark current/ref as good
20 muse bisect skip [<ref>] — skip untestable commit
21 muse bisect run <command> — auto-bisect
22 muse bisect log — show session log
23 muse bisect reset — end session
24 """
25
26 from __future__ import annotations
27
28 import logging
29 from typing import Annotated
30
31 import typer
32
33 from muse.core.bisect import (
34 BisectResult,
35 get_bisect_log,
36 is_bisect_active,
37 mark_bad,
38 mark_good,
39 reset_bisect,
40 run_bisect_command,
41 skip_commit,
42 start_bisect,
43 )
44 from muse.core.errors import ExitCode
45 from muse.core.repo import require_repo
46 from muse.core.store import get_head_commit_id, read_current_branch, resolve_commit_ref
47 from muse.core.validation import sanitize_display
48
49 logger = logging.getLogger(__name__)
50 app = typer.Typer(
51 help="Binary search through commit history to find the first bad commit.",
52 no_args_is_help=True,
53 )
54
55
56 import json
57 import pathlib
58
59
60 def _read_branch(root: pathlib.Path) -> str:
61 return read_current_branch(root)
62
63
64 def _read_repo_id(root: pathlib.Path) -> str:
65 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
66
67
68 def _resolve_ref(root: pathlib.Path, ref: str | None) -> str:
69 """Resolve ref to full commit_id; fall back to HEAD if ref is None."""
70 branch = _read_branch(root)
71 repo_id = _read_repo_id(root)
72 if ref is None:
73 commit_id = get_head_commit_id(root, branch)
74 if not commit_id:
75 typer.echo("❌ No commits on current branch.")
76 raise typer.Exit(code=ExitCode.USER_ERROR)
77 return commit_id
78 commit = resolve_commit_ref(root, repo_id, branch, ref)
79 if commit is None:
80 typer.echo(f"❌ Ref '{sanitize_display(ref)}' not found.")
81 raise typer.Exit(code=ExitCode.USER_ERROR)
82 return commit.commit_id
83
84
85 def _print_result(result: BisectResult) -> None:
86 if result.done:
87 typer.echo(f"\n✅ First bad commit found: {result.first_bad}")
88 typer.echo(" Run 'muse bisect reset' to end the session.")
89 else:
90 typer.echo(
91 f"Next to test: {result.next_to_test} "
92 f"({result.remaining_count} remaining, ~{result.steps_remaining} step(s) left)"
93 )
94
95
96 @app.command("start")
97 def bisect_start(
98 bad: Annotated[str | None, typer.Option("--bad", help="Known-bad commit (default: HEAD).")] = None,
99 good: Annotated[list[str] | None, typer.Option("--good", help="Known-good commit (repeatable).")] = None,
100 ) -> None:
101 """Start a bisect session.
102
103 Mark the first bad and last good commits. Muse will immediately suggest
104 the midpoint commit to test.
105
106 Examples::
107
108 muse bisect start --bad HEAD --good v1.0.0
109 muse bisect start --bad a1b2c3 --good d4e5f6 --good g7h8i9
110 """
111 root = require_repo()
112 if is_bisect_active(root):
113 typer.echo("⚠️ A bisect session is already active. Run 'muse bisect reset' first.")
114 raise typer.Exit(code=ExitCode.USER_ERROR)
115
116 bad_id = _resolve_ref(root, bad)
117 good_ids = [_resolve_ref(root, g) for g in (good or [])]
118 if not good_ids:
119 typer.echo("❌ Provide at least one --good commit: muse bisect start --bad HEAD --good <ref>")
120 raise typer.Exit(code=ExitCode.USER_ERROR)
121
122 branch = _read_branch(root)
123 result = start_bisect(root, bad_id, good_ids, branch=branch)
124 typer.echo(f"Bisect session started. bad={bad_id[:12]} good=[{', '.join(g[:12] for g in good_ids)}]")
125 _print_result(result)
126
127
128 @app.command("bad")
129 def bisect_bad(
130 ref: Annotated[str | None, typer.Argument(help="Commit to mark bad (default: HEAD).")] = None,
131 ) -> None:
132 """Mark a commit as bad (bug present)."""
133 root = require_repo()
134 if not is_bisect_active(root):
135 typer.echo("❌ No bisect session in progress. Run 'muse bisect start' first.")
136 raise typer.Exit(code=ExitCode.USER_ERROR)
137 commit_id = _resolve_ref(root, ref)
138 result = mark_bad(root, commit_id)
139 typer.echo(f"Marked {commit_id[:12]} as bad.")
140 _print_result(result)
141
142
143 @app.command("good")
144 def bisect_good(
145 ref: Annotated[str | None, typer.Argument(help="Commit to mark good (default: HEAD).")] = None,
146 ) -> None:
147 """Mark a commit as good (bug absent)."""
148 root = require_repo()
149 if not is_bisect_active(root):
150 typer.echo("❌ No bisect session in progress. Run 'muse bisect start' first.")
151 raise typer.Exit(code=ExitCode.USER_ERROR)
152 commit_id = _resolve_ref(root, ref)
153 result = mark_good(root, commit_id)
154 typer.echo(f"Marked {commit_id[:12]} as good.")
155 _print_result(result)
156
157
158 @app.command("skip")
159 def bisect_skip(
160 ref: Annotated[str | None, typer.Argument(help="Commit to skip (default: HEAD).")] = None,
161 ) -> None:
162 """Skip a commit that cannot be tested (exit code 125 in auto mode)."""
163 root = require_repo()
164 if not is_bisect_active(root):
165 typer.echo("❌ No bisect session in progress. Run 'muse bisect start' first.")
166 raise typer.Exit(code=ExitCode.USER_ERROR)
167 commit_id = _resolve_ref(root, ref)
168 result = skip_commit(root, commit_id)
169 typer.echo(f"Skipped {commit_id[:12]}.")
170 _print_result(result)
171
172
173 @app.command("run")
174 def bisect_run(
175 command: Annotated[str, typer.Argument(help="Shell command to run at each step.")],
176 ) -> None:
177 """Automatically bisect by running a command at each step.
178
179 The command exit code determines the verdict:
180
181 \\b
182 0 → good
183 125 → skip
184 else → bad
185
186 The command is run in the repository root. Muse will automatically apply
187 verdicts and advance until the first bad commit is found.
188
189 Example::
190
191 muse bisect run "pytest tests/test_regression.py -x -q"
192 """
193 root = require_repo()
194 if not is_bisect_active(root):
195 typer.echo("❌ No bisect session in progress. Run 'muse bisect start' first.")
196 raise typer.Exit(code=ExitCode.USER_ERROR)
197
198 from muse.core.bisect import _load_state
199
200 while True:
201 state = _load_state(root)
202 if state is None:
203 typer.echo("❌ Bisect state lost.")
204 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
205 remaining = state.get("remaining", [])
206 if not remaining:
207 typer.echo("✅ Bisect complete. Run 'muse bisect reset' to end.")
208 return
209 current = remaining[len(remaining) // 2]
210 typer.echo(f" → Testing {current[:12]} …")
211 result = run_bisect_command(root, command, current)
212 typer.echo(f" verdict: {result.verdict}")
213 if result.done:
214 typer.echo(f"\n✅ First bad commit: {result.first_bad}")
215 return
216
217
218 @app.command("log")
219 def bisect_log() -> None:
220 """Show the full bisect session log."""
221 root = require_repo()
222 entries = get_bisect_log(root)
223 if not entries:
224 typer.echo("No bisect log. Start a session with 'muse bisect start'.")
225 return
226 typer.echo("Bisect log:")
227 for entry in entries:
228 typer.echo(f" {entry}")
229
230
231 @app.command("reset")
232 def bisect_reset() -> None:
233 """End the bisect session and clean up state."""
234 root = require_repo()
235 reset_bisect(root)
236 typer.echo("Bisect session reset.")