gabriel / muse public
test_bisect.py python
700 lines 28.7 KB
Raw
1 """Tests for muse bisect — commit-level and symbol-scoped binary search."""
2
3 from __future__ import annotations
4
5 import datetime
6 import json
7 import pathlib
8 import textwrap
9 import uuid
10
11 import pytest
12 from tests.cli_test_helper import CliRunner
13 from muse.core.store import CommitDict
14 from muse.domain import InsertOp, PatchOp, ReplaceOp, StructuredDelta
15
16 cli = None # argparse migration — CliRunner ignores this arg
17
18 runner = CliRunner()
19
20
21 # ---------------------------------------------------------------------------
22 # Fixtures
23 # ---------------------------------------------------------------------------
24
25
26 @pytest.fixture
27 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
28 """Minimal code-domain Muse repo."""
29 monkeypatch.chdir(tmp_path)
30 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
31 r = runner.invoke(cli, ["init", "--domain", "code"])
32 assert r.exit_code == 0, r.output
33 return tmp_path
34
35
36 @pytest.fixture
37 def linear_repo(repo: pathlib.Path) -> pathlib.Path:
38 """Repo with 4 commits: c0 (good) → c1 → c2 → c3 (bad).
39
40 billing.py::compute is added in c0 and modified in c1 and c3.
41 """
42 work = repo
43
44 # c0 — genesis
45 (work / "billing.py").write_text(textwrap.dedent("""\
46 def compute(items):
47 return sum(items)
48 """))
49 r = runner.invoke(cli, ["commit", "-m", "c0: add compute"])
50 assert r.exit_code == 0, r.output
51
52 # c1 — modify compute
53 (work / "billing.py").write_text(textwrap.dedent("""\
54 def compute(items, discount=0.0):
55 return sum(items) * (1 - discount)
56 """))
57 r = runner.invoke(cli, ["commit", "-m", "c1: add discount param"])
58 assert r.exit_code == 0, r.output
59
60 # c2 — unrelated file
61 (work / "utils.py").write_text("def noop(): pass\n")
62 r = runner.invoke(cli, ["commit", "-m", "c2: add utils"])
63 assert r.exit_code == 0, r.output
64
65 # c3 — break compute
66 (work / "billing.py").write_text(textwrap.dedent("""\
67 def compute(items, discount=0.0, tax=0.0):
68 return sum(items) * (1 - discount) + tax
69 """))
70 r = runner.invoke(cli, ["commit", "-m", "c3: add tax param (bad)"])
71 assert r.exit_code == 0, r.output
72
73 return repo
74
75
76 def _commit_ids(repo: pathlib.Path) -> list[str]:
77 """Return all commit IDs oldest-first."""
78 repo_json = json.loads((repo / ".muse" / "repo.json").read_text())
79 repo_id = repo_json["repo_id"]
80 from muse.core.store import read_current_branch, resolve_commit_ref
81 from muse.plugins.code._query import walk_commits_bfs
82 branch = read_current_branch(repo)
83 head = resolve_commit_ref(repo, repo_id, branch, None)
84 assert head is not None
85 commits, _ = walk_commits_bfs(repo, head.commit_id)
86 # Return oldest-first
87 return [c.commit_id for c in reversed(commits)]
88
89
90 # ---------------------------------------------------------------------------
91 # Core engine tests
92 # ---------------------------------------------------------------------------
93
94
95 class TestBisectEngine:
96 """Tests for muse.core.bisect internals."""
97
98 def test_commits_touching_symbol_filters_correctly(
99 self, linear_repo: pathlib.Path
100 ) -> None:
101 from muse.core.bisect import _commits_touching_symbol
102
103 all_ids = _commit_ids(linear_repo)
104 # billing.py::compute was touched in c1 and c3 (not c0 genesis, not c2 utils)
105 touching = _commits_touching_symbol(
106 linear_repo, all_ids, "billing.py::compute"
107 )
108 assert len(touching) >= 1
109 # Every returned commit must have a structured_delta op for this symbol
110 from muse.core.store import read_commit
111 from muse.plugins.code._query import flat_symbol_ops
112 for cid in touching:
113 commit = read_commit(linear_repo, cid)
114 assert commit is not None and commit.structured_delta is not None
115 addrs = [op["address"] for op in flat_symbol_ops(commit.structured_delta["ops"])]
116 assert "billing.py::compute" in addrs
117
118 def test_commits_touching_symbol_excludes_genesis(
119 self, linear_repo: pathlib.Path
120 ) -> None:
121 from muse.core.bisect import _commits_touching_symbol
122 from muse.core.store import read_commit
123
124 all_ids = _commit_ids(linear_repo)
125 # Genesis commit has no structured_delta → must be excluded
126 genesis_id = all_ids[0]
127 genesis = read_commit(linear_repo, genesis_id)
128 assert genesis is not None
129 assert genesis.structured_delta is None or genesis.parent_commit_id is None
130
131 touching = _commits_touching_symbol(
132 linear_repo, all_ids, "billing.py::compute"
133 )
134 assert genesis_id not in touching
135
136 def test_commits_touching_symbol_excludes_unrelated(
137 self, linear_repo: pathlib.Path
138 ) -> None:
139 from muse.core.bisect import _commits_touching_symbol
140
141 all_ids = _commit_ids(linear_repo)
142 # c2 only added utils.py::noop; must not appear for billing.py::compute
143 touching = _commits_touching_symbol(
144 linear_repo, all_ids, "billing.py::compute"
145 )
146 touching_set = set(touching)
147
148 from muse.core.store import read_commit
149 for cid in all_ids:
150 commit = read_commit(linear_repo, cid)
151 if commit is None or commit.message != "c2: add utils":
152 continue
153 assert cid not in touching_set, "c2 (utils only) must not touch billing.py::compute"
154
155 def test_addr_matches_exact(self) -> None:
156 from muse.core.bisect import _addr_matches
157 assert _addr_matches("billing.py::Invoice.compute", "billing.py::Invoice.compute")
158 assert not _addr_matches("billing.py::Invoice.apply", "billing.py::Invoice.compute")
159
160 def test_addr_matches_class_prefix(self) -> None:
161 from muse.core.bisect import _addr_matches
162 # Prefix matching: filtering by class matches all its methods
163 assert _addr_matches("billing.py::Invoice.compute", "billing.py::Invoice")
164 assert _addr_matches("billing.py::Invoice.apply_discount", "billing.py::Invoice")
165 assert not _addr_matches("billing.py::OtherClass.method", "billing.py::Invoice")
166
167 def test_symbol_ops_in_commit_returns_descriptions(
168 self, linear_repo: pathlib.Path
169 ) -> None:
170 from muse.core.bisect import _symbol_ops_in_commit
171
172 all_ids = _commit_ids(linear_repo)
173 # Find a commit that touched billing.py::compute
174 from muse.core.store import read_commit
175 from muse.plugins.code._query import flat_symbol_ops
176 for cid in all_ids:
177 commit = read_commit(linear_repo, cid)
178 if commit is None or commit.structured_delta is None:
179 continue
180 addrs = [op["address"] for op in flat_symbol_ops(commit.structured_delta["ops"])]
181 if "billing.py::compute" in addrs:
182 descriptions = _symbol_ops_in_commit(linear_repo, cid, "billing.py::compute")
183 assert len(descriptions) >= 1
184 # Each description contains the address
185 assert any("billing.py::compute" in d for d in descriptions)
186 break
187
188 def test_symbol_ops_in_commit_empty_for_unrelated(
189 self, linear_repo: pathlib.Path
190 ) -> None:
191 from muse.core.bisect import _symbol_ops_in_commit
192 from muse.core.store import read_commit
193
194 all_ids = _commit_ids(linear_repo)
195 for cid in all_ids:
196 commit = read_commit(linear_repo, cid)
197 if commit is not None and commit.message == "c2: add utils":
198 descriptions = _symbol_ops_in_commit(linear_repo, cid, "billing.py::compute")
199 assert descriptions == []
200 break
201
202 def test_start_bisect_builds_remaining(self, linear_repo: pathlib.Path) -> None:
203 from muse.core.bisect import start_bisect
204
205 all_ids = _commit_ids(linear_repo)
206 bad_id = all_ids[-1] # c3
207 good_id = all_ids[0] # c0
208
209 result = start_bisect(linear_repo, bad_id, [good_id])
210 assert not result.done
211 assert result.next_to_test is not None
212 assert result.remaining_count >= 1
213
214 def test_start_bisect_with_symbol_filter_reduces_remaining(
215 self, linear_repo: pathlib.Path
216 ) -> None:
217 from muse.core.bisect import start_bisect, _commits_touching_symbol
218
219 all_ids = _commit_ids(linear_repo)
220 bad_id = all_ids[-1]
221 good_id = all_ids[0]
222
223 result_plain = start_bisect(linear_repo, bad_id, [good_id])
224 result_sym = start_bisect(linear_repo, bad_id, [good_id], symbol_filter="billing.py::compute")
225
226 # Symbol-scoped bisect must have <= remaining commits than unfiltered
227 assert result_sym.remaining_count <= result_plain.remaining_count
228
229 def test_start_bisect_symbol_filter_persisted(
230 self, linear_repo: pathlib.Path
231 ) -> None:
232 from muse.core.bisect import start_bisect, _load_state
233
234 all_ids = _commit_ids(linear_repo)
235 start_bisect(linear_repo, all_ids[-1], [all_ids[0]], symbol_filter="billing.py::compute")
236 state = _load_state(linear_repo)
237 assert state is not None
238 assert state.get("symbol_filter") == "billing.py::compute"
239
240 def test_start_bisect_symbol_filter_populates_symbol_changes(
241 self, linear_repo: pathlib.Path
242 ) -> None:
243 from muse.core.bisect import start_bisect
244
245 all_ids = _commit_ids(linear_repo)
246 result = start_bisect(
247 linear_repo, all_ids[-1], [all_ids[0]], symbol_filter="billing.py::compute"
248 )
249 if not result.done and result.next_to_test:
250 # symbol_changes should be populated (non-empty) because next_to_test
251 # touches billing.py::compute (it's in the filtered set)
252 assert isinstance(result.symbol_changes, list)
253
254 def test_apply_verdict_preserves_symbol_filter(
255 self, linear_repo: pathlib.Path
256 ) -> None:
257 from muse.core.bisect import start_bisect, mark_good, _load_state
258
259 all_ids = _commit_ids(linear_repo)
260 result = start_bisect(
261 linear_repo, all_ids[-1], [all_ids[0]], symbol_filter="billing.py::compute"
262 )
263 if result.done or result.next_to_test is None:
264 return # too few commits, skip
265 mark_good(linear_repo, result.next_to_test)
266 state = _load_state(linear_repo)
267 assert state is not None
268 assert state.get("symbol_filter") == "billing.py::compute"
269
270 def test_bisect_converges_to_first_bad(self, linear_repo: pathlib.Path) -> None:
271 from muse.core.bisect import start_bisect, mark_bad, mark_good
272
273 all_ids = _commit_ids(linear_repo)
274 bad_id = all_ids[-1]
275 good_id = all_ids[0]
276
277 result = start_bisect(linear_repo, bad_id, [good_id])
278 max_steps = 20 # safety ceiling
279 for _ in range(max_steps):
280 if result.done:
281 break
282 assert result.next_to_test is not None
283 # Simplified oracle: all commits after c0 are "bad" in our fixture
284 result = mark_bad(linear_repo, result.next_to_test)
285 assert result.done
286 assert result.first_bad is not None
287
288 def test_skip_commit_removes_from_remaining(self, linear_repo: pathlib.Path) -> None:
289 from muse.core.bisect import start_bisect, skip_commit, _load_state
290
291 all_ids = _commit_ids(linear_repo)
292 result = start_bisect(linear_repo, all_ids[-1], [all_ids[0]])
293 if result.done or result.next_to_test is None:
294 return
295 skip_target = result.next_to_test
296 skip_commit(linear_repo, skip_target)
297 state = _load_state(linear_repo)
298 assert state is not None
299 assert skip_target not in state.get("remaining", [])
300
301 def test_reset_clears_state(self, linear_repo: pathlib.Path) -> None:
302 from muse.core.bisect import start_bisect, reset_bisect, is_bisect_active
303
304 all_ids = _commit_ids(linear_repo)
305 start_bisect(linear_repo, all_ids[-1], [all_ids[0]])
306 assert is_bisect_active(linear_repo)
307 reset_bisect(linear_repo)
308 assert not is_bisect_active(linear_repo)
309
310
311 # ---------------------------------------------------------------------------
312 # CLI tests — baseline muse bisect commands
313 # ---------------------------------------------------------------------------
314
315
316 class TestBisectCLI:
317 """End-to-end CLI tests for muse bisect."""
318
319 def test_bisect_start_exits_zero(self, linear_repo: pathlib.Path) -> None:
320 ids = _commit_ids(linear_repo)
321 r = runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
322 assert r.exit_code == 0, r.output
323 # Clean up
324 runner.invoke(cli, ["bisect", "reset"])
325
326 def test_bisect_start_shows_next_to_test(self, linear_repo: pathlib.Path) -> None:
327 ids = _commit_ids(linear_repo)
328 r = runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
329 assert r.exit_code == 0, r.output
330 assert "Next to test" in r.output or "First bad commit" in r.output
331 runner.invoke(cli, ["bisect", "reset"])
332
333 def test_bisect_start_requires_good(self, linear_repo: pathlib.Path) -> None:
334 ids = _commit_ids(linear_repo)
335 r = runner.invoke(cli, ["bisect", "start", "--bad", ids[-1]])
336 assert r.exit_code != 0
337
338 def test_bisect_bad_good_cycle(self, linear_repo: pathlib.Path) -> None:
339 ids = _commit_ids(linear_repo)
340 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
341 # Mark the midpoint as bad, keep narrowing
342 from muse.core.bisect import _load_state
343 state = _load_state(linear_repo)
344 assert state is not None
345 remaining = state.get("remaining", [])
346 if remaining:
347 mid = remaining[len(remaining) // 2]
348 r = runner.invoke(cli, ["bisect", "bad", mid])
349 assert r.exit_code == 0, r.output
350 runner.invoke(cli, ["bisect", "reset"])
351
352 def test_bisect_commands_require_active_session(self, repo: pathlib.Path) -> None:
353 for sub in ("bad", "good", "skip"):
354 r = runner.invoke(cli, ["bisect", sub])
355 assert r.exit_code != 0
356
357 def test_bisect_double_start_fails(self, linear_repo: pathlib.Path) -> None:
358 ids = _commit_ids(linear_repo)
359 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
360 r = runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
361 assert r.exit_code != 0
362 runner.invoke(cli, ["bisect", "reset"])
363
364 def test_bisect_log_shows_entries(self, linear_repo: pathlib.Path) -> None:
365 ids = _commit_ids(linear_repo)
366 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
367 r = runner.invoke(cli, ["bisect", "log"])
368 assert r.exit_code == 0, r.output
369 assert "bad" in r.output or "good" in r.output
370 runner.invoke(cli, ["bisect", "reset"])
371
372 def test_bisect_log_empty_without_session(self, repo: pathlib.Path) -> None:
373 r = runner.invoke(cli, ["bisect", "log"])
374 assert r.exit_code == 0
375 assert "No bisect log" in r.output or r.output.strip() == ""
376
377 def test_bisect_reset_exits_zero(self, linear_repo: pathlib.Path) -> None:
378 ids = _commit_ids(linear_repo)
379 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
380 r = runner.invoke(cli, ["bisect", "reset"])
381 assert r.exit_code == 0, r.output
382
383 def test_bisect_reset_clears_active(self, linear_repo: pathlib.Path) -> None:
384 ids = _commit_ids(linear_repo)
385 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
386 runner.invoke(cli, ["bisect", "reset"])
387 from muse.core.bisect import is_bisect_active
388 assert not is_bisect_active(linear_repo)
389
390 def test_bisect_run_command_auto_bisect(self, linear_repo: pathlib.Path) -> None:
391 """Auto-bisect with a command that always says good — terminates cleanly."""
392 ids = _commit_ids(linear_repo)
393 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
394 # "exit 0" always → all commits good → bad_id is first bad immediately
395 r = runner.invoke(cli, ["bisect", "run", "true"])
396 assert r.exit_code == 0, r.output
397 assert "First bad commit" in r.output or "complete" in r.output.lower()
398 runner.invoke(cli, ["bisect", "reset"])
399
400 def test_bisect_requires_repo(
401 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
402 ) -> None:
403 monkeypatch.chdir(tmp_path)
404 r = runner.invoke(cli, ["bisect", "start", "--bad", "abc", "--good", "def"])
405 assert r.exit_code != 0
406
407
408 # ---------------------------------------------------------------------------
409 # CLI tests — --symbol flag
410 # ---------------------------------------------------------------------------
411
412
413 class TestBisectSymbol:
414 """Tests for the --symbol scoping feature on muse bisect start."""
415
416 def test_symbol_start_exits_zero(self, linear_repo: pathlib.Path) -> None:
417 ids = _commit_ids(linear_repo)
418 r = runner.invoke(cli, [
419 "bisect", "start",
420 "--bad", ids[-1],
421 "--good", ids[0],
422 "--symbol", "billing.py::compute",
423 ])
424 assert r.exit_code == 0, r.output
425 runner.invoke(cli, ["bisect", "reset"])
426
427 def test_symbol_start_shows_session_started(self, linear_repo: pathlib.Path) -> None:
428 ids = _commit_ids(linear_repo)
429 r = runner.invoke(cli, [
430 "bisect", "start",
431 "--bad", ids[-1],
432 "--good", ids[0],
433 "--symbol", "billing.py::compute",
434 ])
435 assert "Bisect session started" in r.output
436 assert "billing.py::compute" in r.output
437 runner.invoke(cli, ["bisect", "reset"])
438
439 def test_symbol_reduces_remaining_vs_plain(self, linear_repo: pathlib.Path) -> None:
440 """Symbol-scoped bisect must have fewer or equal remaining commits."""
441 ids = _commit_ids(linear_repo)
442
443 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
444 from muse.core.bisect import _load_state
445 plain_state = _load_state(linear_repo)
446 plain_remaining = len(plain_state.get("remaining", [])) if plain_state else 0
447 runner.invoke(cli, ["bisect", "reset"])
448
449 runner.invoke(cli, [
450 "bisect", "start",
451 "--bad", ids[-1],
452 "--good", ids[0],
453 "--symbol", "billing.py::compute",
454 ])
455 sym_state = _load_state(linear_repo)
456 sym_remaining = len(sym_state.get("remaining", [])) if sym_state else 0
457 runner.invoke(cli, ["bisect", "reset"])
458
459 assert sym_remaining <= plain_remaining
460
461 def test_symbol_filter_persisted_in_state(self, linear_repo: pathlib.Path) -> None:
462 ids = _commit_ids(linear_repo)
463 runner.invoke(cli, [
464 "bisect", "start",
465 "--bad", ids[-1],
466 "--good", ids[0],
467 "--symbol", "billing.py::compute",
468 ])
469 from muse.core.bisect import _load_state
470 state = _load_state(linear_repo)
471 assert state is not None
472 assert state.get("symbol_filter") == "billing.py::compute"
473 runner.invoke(cli, ["bisect", "reset"])
474
475 def test_symbol_filter_persisted_after_mark_good(self, linear_repo: pathlib.Path) -> None:
476 """symbol_filter must survive a mark-good verdict."""
477 ids = _commit_ids(linear_repo)
478 runner.invoke(cli, [
479 "bisect", "start",
480 "--bad", ids[-1],
481 "--good", ids[0],
482 "--symbol", "billing.py::compute",
483 ])
484 from muse.core.bisect import _load_state
485 state = _load_state(linear_repo)
486 if state and state.get("remaining"):
487 mid = state["remaining"][len(state["remaining"]) // 2]
488 runner.invoke(cli, ["bisect", "good", mid])
489 state2 = _load_state(linear_repo)
490 if state2:
491 assert state2.get("symbol_filter") == "billing.py::compute"
492 runner.invoke(cli, ["bisect", "reset"])
493
494 def test_symbol_shows_changes_in_output(self, linear_repo: pathlib.Path) -> None:
495 """When next_to_test touches the symbol, output shows symbol changes."""
496 ids = _commit_ids(linear_repo)
497 r = runner.invoke(cli, [
498 "bisect", "start",
499 "--bad", ids[-1],
500 "--good", ids[0],
501 "--symbol", "billing.py::compute",
502 ])
503 assert r.exit_code == 0, r.output
504 # If there are commits to test, the output should mention the symbol
505 # or at least the next-to-test prompt.
506 assert "Next to test" in r.output or "First bad commit" in r.output
507 runner.invoke(cli, ["bisect", "reset"])
508
509 def test_symbol_invalid_no_double_colon(self, linear_repo: pathlib.Path) -> None:
510 """--symbol without '::' is an invalid address and must be rejected."""
511 ids = _commit_ids(linear_repo)
512 r = runner.invoke(cli, [
513 "bisect", "start",
514 "--bad", ids[-1],
515 "--good", ids[0],
516 "--symbol", "billing.py",
517 ])
518 assert r.exit_code != 0
519
520 def test_symbol_too_long_rejected(self, linear_repo: pathlib.Path) -> None:
521 """--symbol longer than 500 chars must be rejected."""
522 ids = _commit_ids(linear_repo)
523 long_addr = "billing.py::" + "x" * 500
524 r = runner.invoke(cli, [
525 "bisect", "start",
526 "--bad", ids[-1],
527 "--good", ids[0],
528 "--symbol", long_addr,
529 ])
530 assert r.exit_code != 0
531
532 def test_symbol_nonexistent_warns(self, linear_repo: pathlib.Path) -> None:
533 """--symbol with no matching commits shows a warning, exits zero."""
534 ids = _commit_ids(linear_repo)
535 r = runner.invoke(cli, [
536 "bisect", "start",
537 "--bad", ids[-1],
538 "--good", ids[0],
539 "--symbol", "billing.py::nonexistent_symbol_xyz",
540 ])
541 assert r.exit_code == 0
542 # Should warn that no commits matched
543 assert "No commits" in r.output or "First bad commit" in r.output
544 runner.invoke(cli, ["bisect", "reset"])
545
546 def test_symbol_class_prefix_matches_methods(self, repo: pathlib.Path) -> None:
547 """--symbol billing.py::Invoice matches Invoice.compute and Invoice.apply."""
548 # Build a repo with a class that has methods.
549 (repo / "billing.py").write_text(textwrap.dedent("""\
550 class Invoice:
551 def compute(self, items):
552 return sum(items)
553 def apply_discount(self, total, pct):
554 return total * (1 - pct)
555 """))
556 r = runner.invoke(cli, ["commit", "-m", "add Invoice class"])
557 assert r.exit_code == 0, r.output
558
559 (repo / "billing.py").write_text(textwrap.dedent("""\
560 class Invoice:
561 def compute(self, items, discount=0.0):
562 return sum(items) * (1 - discount)
563 def apply_discount(self, total, pct):
564 return total * (1 - pct)
565 """))
566 r = runner.invoke(cli, ["commit", "-m", "add discount to compute"])
567 assert r.exit_code == 0, r.output
568
569 from muse.core.bisect import _commits_touching_symbol
570
571 ids = _commit_ids(repo)
572 # Filter by class prefix — should include the commit touching Invoice.compute
573 touching_class = _commits_touching_symbol(repo, ids, "billing.py::Invoice")
574 touching_method = _commits_touching_symbol(repo, ids, "billing.py::Invoice.compute")
575 # Class prefix must be a superset
576 assert set(touching_method) <= set(touching_class)
577
578 def test_symbol_with_run_command(self, linear_repo: pathlib.Path) -> None:
579 """bisect run with --symbol filter uses symbol-narrowed remaining list."""
580 ids = _commit_ids(linear_repo)
581 runner.invoke(cli, [
582 "bisect", "start",
583 "--bad", ids[-1],
584 "--good", ids[0],
585 "--symbol", "billing.py::compute",
586 ])
587 # Use "true" (always exits 0 = good) to drive the bisect to completion.
588 r = runner.invoke(cli, ["bisect", "run", "true"])
589 assert r.exit_code == 0, r.output
590 assert "First bad commit" in r.output or "complete" in r.output.lower()
591 runner.invoke(cli, ["bisect", "reset"])
592
593 def test_symbol_state_roundtrip(self, linear_repo: pathlib.Path) -> None:
594 """Symbol filter survives save → load roundtrip through TOML."""
595 from muse.core.bisect import _save_state, _load_state, BisectStateDict
596
597 state: BisectStateDict = {
598 "bad_id": "ab" * 32,
599 "good_ids": ["cd" * 32],
600 "skipped_ids": [],
601 "remaining": [],
602 "log": [],
603 "symbol_filter": "billing.py::Invoice.compute_total",
604 }
605 _save_state(linear_repo, state)
606 loaded = _load_state(linear_repo)
607 assert loaded is not None
608 assert loaded.get("symbol_filter") == "billing.py::Invoice.compute_total"
609
610 def test_symbol_filter_special_chars_survive_toml(
611 self, linear_repo: pathlib.Path
612 ) -> None:
613 """Symbol addresses with dots survive TOML serialisation."""
614 from muse.core.bisect import _save_state, _load_state, BisectStateDict
615
616 addr = 'billing.py::Invoice.compute_total'
617 state: BisectStateDict = {
618 "bad_id": "ab" * 32,
619 "good_ids": ["cd" * 32],
620 "skipped_ids": [],
621 "remaining": [],
622 "log": [],
623 "symbol_filter": addr,
624 }
625 _save_state(linear_repo, state)
626 loaded = _load_state(linear_repo)
627 assert loaded is not None
628 assert loaded.get("symbol_filter") == addr
629
630 def test_symbol_filter_with_merge_commit(self, repo: pathlib.Path) -> None:
631 """_commits_touching_symbol finds events on feature-branch commits (parent2)."""
632 # Genesis commit
633 (repo / "core.py").write_text("def bedrock():\n return 42\n")
634 r = runner.invoke(cli, ["commit", "-m", "Add bedrock"])
635 assert r.exit_code == 0, r.output
636
637 repo_json = json.loads((repo / ".muse" / "repo.json").read_text())
638 repo_id = repo_json["repo_id"]
639 from muse.core.store import read_current_branch, resolve_commit_ref
640 branch = read_current_branch(repo)
641 head_commit = resolve_commit_ref(repo, repo_id, branch, None)
642 assert head_commit is not None
643 head_id = head_commit.commit_id
644
645 now = datetime.datetime(2026, 3, 1, tzinfo=datetime.timezone.utc)
646 from muse.core.snapshot import compute_commit_id as _cid
647 feature_snap_id = head_commit.snapshot_id
648 merge_snap_id = head_commit.snapshot_id
649 feature_id = _cid([head_id], feature_snap_id, "Modify bedrock on feature", now.isoformat())
650 merge_id = _cid([head_id, feature_id], merge_snap_id, "Merge feature", now.isoformat())
651
652 bedrock_delta = StructuredDelta(
653 domain="code",
654 ops=[PatchOp(
655 op="patch", address="core.py",
656 child_ops=[ReplaceOp(
657 op="replace", address="core.py::bedrock",
658 old_content_id="a" * 64, new_content_id="b" * 64,
659 old_summary="function bedrock",
660 new_summary="function bedrock (modified)", position=None,
661 )],
662 child_domain="code", child_summary="bedrock modified",
663 )],
664 summary="bedrock modified",
665 )
666 feature_body: CommitDict = {
667 "commit_id": feature_id,
668 "repo_id": repo_id,
669 "branch": "feat/bedrock",
670 "snapshot_id": head_commit.snapshot_id,
671 "message": "Modify bedrock on feature",
672 "committed_at": now.isoformat(),
673 "parent_commit_id": head_id,
674 "parent2_commit_id": None,
675 "author": "test",
676 "metadata": {},
677 "structured_delta": bedrock_delta,
678 }
679 merge_body: CommitDict = {
680 "commit_id": merge_id,
681 "repo_id": repo_id,
682 "branch": branch,
683 "snapshot_id": head_commit.snapshot_id,
684 "message": "Merge feature",
685 "committed_at": now.isoformat(),
686 "parent_commit_id": head_id,
687 "parent2_commit_id": feature_id,
688 "author": "test",
689 "metadata": {},
690 "structured_delta": None,
691 }
692 from muse.core.store import write_commit, CommitRecord
693 write_commit(repo, CommitRecord.from_dict(feature_body))
694 write_commit(repo, CommitRecord.from_dict(merge_body))
695 (repo / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
696
697 all_ids = _commit_ids(repo)
698 from muse.core.bisect import _commits_touching_symbol
699 touching = _commits_touching_symbol(repo, all_ids, "core.py::bedrock")
700 assert feature_id in touching, "feature-branch commit must be found by _commits_touching_symbol"
File History 1 commit