gabriel / muse public

test_cmd_bisect.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:b adding issues docs to bust staging mpack prebuild cache. · gabriel · Jun 20, 2026
1 """Comprehensive tests for ``muse bisect`` — binary search for bad commits.
2
3 Coverage:
4 - Unit: bisect core functions (start, mark, skip, reset)
5 - Integration: CLI subcommands (start, bad, good, skip, log, reset)
6 - E2E: full bisect workflow resolving to a first-bad commit
7 - Security: invalid refs, session guard (no double-start), ref sanitization
8 - Stress: deep commit history bisect
9 """
10
11 from __future__ import annotations
12
13 import datetime
14 import json
15 import pathlib
16
17 import pytest
18 from tests.cli_test_helper import CliRunner
19
20 cli = None # argparse migration — CliRunner ignores this arg
21 from muse.core.commits import (
22 CommitRecord,
23 write_commit,
24 )
25 from muse.core.snapshots import (
26 SnapshotRecord,
27 write_snapshot,
28 )
29 from muse.core.ids import hash_commit, hash_snapshot
30 from muse.core.types import Manifest, fake_id
31 from muse.core.paths import muse_dir, ref_path
32
33 runner = CliRunner()
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
42 repo_id = fake_id("repo")
43 dot_muse = muse_dir(tmp_path)
44 dot_muse.mkdir()
45 (dot_muse / "repo.json").write_text(
46 json.dumps({"repo_id": repo_id, "domain": "midi",
47 "default_branch": "main",
48 "created_at": "2026-01-01T00:00:00+00:00"})
49 )
50 (dot_muse / "HEAD").write_text("ref: refs/heads/main")
51 (dot_muse / "refs" / "heads").mkdir(parents=True)
52 (dot_muse / "snapshots").mkdir()
53 (dot_muse / "commits").mkdir()
54 (dot_muse / "objects").mkdir()
55 return tmp_path, repo_id
56
57
58 def _env(root: pathlib.Path) -> Manifest:
59 return {"MUSE_REPO_ROOT": str(root)}
60
61
62 def _make_commit(
63 root: pathlib.Path,
64 repo_id: str,
65 *,
66 branch: str = "main",
67 message: str = "commit",
68 parent_id: str | None = None,
69 ) -> str:
70 manifest: Manifest = {}
71 snap_id = hash_snapshot(manifest)
72 committed_at = datetime.datetime.now(datetime.timezone.utc)
73 commit_id = hash_commit( parent_ids=[parent_id] if parent_id else [],
74 snapshot_id=snap_id,
75 message=message,
76 committed_at_iso=committed_at.isoformat(),
77 )
78 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
79 write_commit(root, CommitRecord(
80 commit_id=commit_id,
81 branch=branch,
82 snapshot_id=snap_id,
83 message=message,
84 committed_at=committed_at,
85 parent_commit_id=parent_id,
86 ))
87 ref_file = ref_path(root, branch)
88 ref_file.parent.mkdir(parents=True, exist_ok=True)
89 ref_file.write_text(commit_id)
90 return commit_id
91
92
93 def _make_chain(root: pathlib.Path, repo_id: str, n: int) -> list[str]:
94 """Create a linear chain of n commits; return commit IDs oldest-first."""
95 ids: list[str] = []
96 parent: str | None = None
97 for i in range(n):
98 cid = _make_commit(root, repo_id, message=f"commit-{i}", parent_id=parent)
99 ids.append(cid)
100 parent = cid
101 return ids
102
103
104 # ---------------------------------------------------------------------------
105 # Unit tests — core bisect logic
106 # ---------------------------------------------------------------------------
107
108
109 class TestBisectCore:
110 def test_start_bisect_returns_result(self, tmp_path: pathlib.Path) -> None:
111 root, repo_id = _init_repo(tmp_path)
112 ids = _make_chain(root, repo_id, 4)
113 from muse.core.bisect import start_bisect
114 result = start_bisect(root, ids[-1], [ids[0]], branch="main")
115 assert result.next_to_test is not None or result.done
116
117 def test_mark_bad_advances_search(self, tmp_path: pathlib.Path) -> None:
118 root, repo_id = _init_repo(tmp_path)
119 ids = _make_chain(root, repo_id, 8)
120 from muse.core.bisect import mark_bad, start_bisect
121 start_bisect(root, ids[-1], [ids[0]], branch="main")
122 result = mark_bad(root, ids[-1])
123 assert not result.done or result.first_bad is not None
124
125 def test_mark_good_advances_search(self, tmp_path: pathlib.Path) -> None:
126 root, repo_id = _init_repo(tmp_path)
127 ids = _make_chain(root, repo_id, 8)
128 from muse.core.bisect import mark_good, start_bisect
129 start_bisect(root, ids[-1], [ids[0]], branch="main")
130 result = mark_good(root, ids[0])
131 assert result is not None
132
133 def test_reset_clears_state(self, tmp_path: pathlib.Path) -> None:
134 root, repo_id = _init_repo(tmp_path)
135 ids = _make_chain(root, repo_id, 4)
136 from muse.core.bisect import is_bisect_active, reset_bisect, start_bisect
137 start_bisect(root, ids[-1], [ids[0]], branch="main")
138 assert is_bisect_active(root)
139 reset_bisect(root)
140 assert not is_bisect_active(root)
141
142 def test_bisect_log_records_events(self, tmp_path: pathlib.Path) -> None:
143 root, repo_id = _init_repo(tmp_path)
144 ids = _make_chain(root, repo_id, 4)
145 from muse.core.bisect import get_bisect_log, start_bisect
146 start_bisect(root, ids[-1], [ids[0]], branch="main")
147 log = get_bisect_log(root)
148 assert len(log) > 0
149
150
151 # ---------------------------------------------------------------------------
152 # Integration tests — CLI subcommands
153 # ---------------------------------------------------------------------------
154
155
156 class TestBisectCLI:
157 def test_start_requires_good_ref(self, tmp_path: pathlib.Path) -> None:
158 root, repo_id = _init_repo(tmp_path)
159 ids = _make_chain(root, repo_id, 2)
160 result = runner.invoke(
161 cli, ["bisect", "start", "--bad", ids[-1]],
162 env=_env(root)
163 )
164 assert result.exit_code != 0
165 assert "good" in result.stderr.lower()
166
167 def test_start_with_bad_and_good(self, tmp_path: pathlib.Path) -> None:
168 root, repo_id = _init_repo(tmp_path)
169 ids = _make_chain(root, repo_id, 4)
170 result = runner.invoke(
171 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
172 env=_env(root), catch_exceptions=False
173 )
174 assert result.exit_code == 0
175 assert "started" in result.output.lower() or "next" in result.output.lower()
176
177 def test_bad_without_session_fails(self, tmp_path: pathlib.Path) -> None:
178 root, repo_id = _init_repo(tmp_path)
179 ids = _make_chain(root, repo_id, 2)
180 result = runner.invoke(cli, ["bisect", "bad", ids[-1]], env=_env(root))
181 assert result.exit_code != 0
182
183 def test_good_without_session_fails(self, tmp_path: pathlib.Path) -> None:
184 root, repo_id = _init_repo(tmp_path)
185 ids = _make_chain(root, repo_id, 2)
186 result = runner.invoke(cli, ["bisect", "good", ids[0]], env=_env(root))
187 assert result.exit_code != 0
188
189 def test_skip_without_session_fails(self, tmp_path: pathlib.Path) -> None:
190 root, repo_id = _init_repo(tmp_path)
191 ids = _make_chain(root, repo_id, 2)
192 result = runner.invoke(cli, ["bisect", "skip", ids[0]], env=_env(root))
193 assert result.exit_code != 0
194
195 def test_reset_clears_session(self, tmp_path: pathlib.Path) -> None:
196 root, repo_id = _init_repo(tmp_path)
197 ids = _make_chain(root, repo_id, 4)
198 runner.invoke(
199 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
200 env=_env(root)
201 )
202 result = runner.invoke(cli, ["bisect", "reset"], env=_env(root), catch_exceptions=False)
203 assert result.exit_code == 0
204 # After reset, bad should fail
205 result2 = runner.invoke(cli, ["bisect", "bad", ids[-1]], env=_env(root))
206 assert result2.exit_code != 0
207
208 def test_log_shows_entries(self, tmp_path: pathlib.Path) -> None:
209 root, repo_id = _init_repo(tmp_path)
210 ids = _make_chain(root, repo_id, 4)
211 runner.invoke(
212 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
213 env=_env(root)
214 )
215 result = runner.invoke(cli, ["bisect", "log"], env=_env(root), catch_exceptions=False)
216 assert result.exit_code == 0
217
218 def test_double_start_fails(self, tmp_path: pathlib.Path) -> None:
219 root, repo_id = _init_repo(tmp_path)
220 ids = _make_chain(root, repo_id, 4)
221 runner.invoke(
222 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
223 env=_env(root)
224 )
225 result = runner.invoke(
226 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
227 env=_env(root)
228 )
229 assert result.exit_code != 0
230 assert "already" in result.stderr.lower()
231
232 def test_bad_invalid_ref_fails(self, tmp_path: pathlib.Path) -> None:
233 root, repo_id = _init_repo(tmp_path)
234 ids = _make_chain(root, repo_id, 4)
235 runner.invoke(
236 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
237 env=_env(root)
238 )
239 result = runner.invoke(cli, ["bisect", "bad", "deadbeef"], env=_env(root))
240 assert result.exit_code != 0
241
242 def test_reset_without_session_succeeds(self, tmp_path: pathlib.Path) -> None:
243 """reset when no session is active should not crash."""
244 root, _ = _init_repo(tmp_path)
245 result = runner.invoke(cli, ["bisect", "reset"], env=_env(root), catch_exceptions=False)
246 assert result.exit_code == 0
247
248 def test_log_empty_without_session(self, tmp_path: pathlib.Path) -> None:
249 root, _ = _init_repo(tmp_path)
250 result = runner.invoke(cli, ["bisect", "log"], env=_env(root), catch_exceptions=False)
251 assert result.exit_code == 0
252 assert "no bisect" in result.output.lower() or result.output.strip() == "" or "no" in result.output.lower()
253
254
255 # ---------------------------------------------------------------------------
256 # E2E tests
257 # ---------------------------------------------------------------------------
258
259
260 class TestBisectE2E:
261 def test_full_bisect_workflow_2_commits(self, tmp_path: pathlib.Path) -> None:
262 """Start → mark good → mark bad → find first bad commit."""
263 root, repo_id = _init_repo(tmp_path)
264 ids = _make_chain(root, repo_id, 2)
265 good_id, bad_id = ids[0], ids[1]
266
267 runner.invoke(
268 cli, ["bisect", "start", "--bad", bad_id, "--good", good_id],
269 env=_env(root)
270 )
271 # With only 2 commits, bisect should already identify bad_id
272 from muse.core.bisect import get_bisect_log
273 log = get_bisect_log(root)
274 assert len(log) >= 1
275
276 def test_full_bisect_workflow_many_commits(self, tmp_path: pathlib.Path) -> None:
277 """With a chain of 8 commits, bisect converges without error."""
278 root, repo_id = _init_repo(tmp_path)
279 ids = _make_chain(root, repo_id, 8)
280
281 runner.invoke(
282 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
283 env=_env(root), catch_exceptions=False
284 )
285
286 from muse.core.bisect import _load_state, is_bisect_active, mark_bad, mark_good
287 # Simulate binary search: assume the bug was introduced at ids[4]
288 max_steps = 20
289 steps = 0
290 done = False
291 while is_bisect_active(root) and steps < max_steps and not done:
292 state = _load_state(root)
293 if state is None:
294 break
295 remaining = state.get("remaining", [])
296 if not remaining:
297 break
298 mid = remaining[len(remaining) // 2]
299 # ids[4] and later are "bad"
300 if mid in ids[4:]:
301 result = mark_bad(root, mid)
302 else:
303 result = mark_good(root, mid)
304 done = result.done
305 steps += 1
306
307 # Bisect should have converged or be close
308 assert done or steps < max_steps
309
310
311 # ---------------------------------------------------------------------------
312 # Security tests
313 # ---------------------------------------------------------------------------
314
315
316 class TestBisectSecurity:
317 def test_ref_with_control_chars_is_rejected(self, tmp_path: pathlib.Path) -> None:
318 root, repo_id = _init_repo(tmp_path)
319 ids = _make_chain(root, repo_id, 2)
320 runner.invoke(
321 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
322 env=_env(root)
323 )
324 # Inject control chars in a bad ref
325 result = runner.invoke(cli, ["bisect", "bad", "\x1b[31minjection\x1b[0m"], env=_env(root))
326 assert result.exit_code != 0
327
328 def test_output_contains_no_ansi_on_invalid_ref(self, tmp_path: pathlib.Path) -> None:
329 root, repo_id = _init_repo(tmp_path)
330 ids = _make_chain(root, repo_id, 2)
331 runner.invoke(
332 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
333 env=_env(root)
334 )
335 result = runner.invoke(cli, ["bisect", "bad", "nonexistent-ref\x1b[31m"], env=_env(root))
336 assert "\x1b[31m" not in result.output
337
338
339 # ---------------------------------------------------------------------------
340 # Stress tests
341 # ---------------------------------------------------------------------------
342
343
344 class TestBisectStress:
345 def test_bisect_50_commit_chain(self, tmp_path: pathlib.Path) -> None:
346 """A 50-commit chain converges within log2(50) + 2 ≈ 8 steps."""
347 root, repo_id = _init_repo(tmp_path)
348 ids = _make_chain(root, repo_id, 50)
349 bad_start = 25 # regression introduced at index 25
350
351 result = runner.invoke(
352 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
353 env=_env(root)
354 )
355 assert result.exit_code == 0
356
357 from muse.core.bisect import _load_state, is_bisect_active, mark_bad, mark_good
358 max_steps = 10 # ceil(log2(48)) = 6; allow generous headroom
359 steps = 0
360 done = False
361 while is_bisect_active(root) and steps < max_steps and not done:
362 state = _load_state(root)
363 if state is None:
364 break
365 remaining = state.get("remaining", [])
366 if not remaining:
367 break
368 mid = remaining[len(remaining) // 2]
369 idx = ids.index(mid) if mid in ids else -1
370 if idx >= bad_start:
371 result = mark_bad(root, mid)
372 else:
373 result = mark_good(root, mid)
374 done = result.done
375 steps += 1
376
377 assert done or steps < max_steps, f"Bisect failed to converge in {steps} steps"