gabriel / muse public
test_cmd_plan_merge.py python
1,498 lines 69.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for ``muse coord plan-merge``.
2
3 Coverage matrix
4 ---------------
5 Unit
6 ~~~~
7 * :func:`_classify_change` — all six change classifications
8 * :func:`_classify_conflict` — all three-way matrix cells
9 (base/ours/theirs combinations: 8 cases + rename, symbol_edit_overlap)
10 * :func:`_find_renames_and_moves` — rename vs move discrimination, trivial body skipped
11 * :func:`_find_delete_use_conflicts` — new callers detected, call graph errors handled
12 * :func:`_find_dependency_conflicts` — transitive dependency detection, errors handled
13 * :class:`_MergeItem` — to_dict, slots
14
15 Integration (mock-based — no real commits required)
16 ~~~~~~~~~~~
17 * ``plan-merge OURS THEIRS`` — ref not found → exit 1
18 * ``plan-merge OURS THEIRS`` — theirs ref not found → exit 1
19 * ``plan-merge OURS THEIRS --base MISSING`` — base ref not found → exit 1
20 * ``plan-merge OURS THEIRS`` — base auto-computed
21 * ``plan-merge OURS THEIRS --base BASE_REF``
22 * ``plan-merge OURS THEIRS --skip-call-graph``
23 * ``plan-merge OURS THEIRS --format json`` — schema complete
24 * ``plan-merge OURS THEIRS --json`` — shorthand
25 * JSON: ``base_auto_computed``, ``call_graph_available``, ``warnings``,
26 ``elapsed_seconds``, full commit IDs, ``conflicts_by_type`` breakdown
27 * JSON output is compact (no indent — single line)
28 * Text output: conflict summary, warnings visible, elapsed present
29 * ``symbol_edit_overlap`` detected (both changed, content differs)
30 * ``rename_edit`` detected via body_hash matching
31 * ``move_edit`` detected via body_hash + file prefix mismatch
32 * ``delete_use`` detected via forward call graph
33 * ``dependency_conflict`` detected via reverse call graph
34 * ``no_conflict`` for unilateral changes (three-way correctness)
35 * call_graph unavailable → warning, not crash
36 * unexpected call graph exception propagates
37
38 Error shapes
39 ~~~~~~~~~~~~
40 * JSON error has ``{"error": ..., "status": "error"}``
41 * Text error uses ``❌`` prefix on stderr
42 * theirs-ref not-found JSON error shape
43 * base-ref not-found JSON error shape
44
45 Security
46 ~~~~~~~~
47 * ANSI sequences in ref names, addresses, change descriptions stripped
48 * Path traversal in ref args → resolve_commit_ref handles it (no FS access)
49
50 Stress
51 ~~~~~~
52 * 1000 symbols across ours + theirs → Pass 1 in < 2 s
53 * 200 renames → Pass 2 in < 1 s
54 * delete_use with 100 deleted symbols → < 2 s
55 * conflicts_by_type counts correct with mixed conflict types at scale
56
57 E2E
58 ~~~
59 * Same commit for ours and theirs → 0 conflicts
60 * Three distinct conflict types in one plan → all appear in conflicts_by_type
61 """
62
63 from __future__ import annotations
64
65 import argparse
66 import json
67 import os
68 import pathlib
69 import sys
70 import time
71 import uuid
72 from unittest.mock import MagicMock, patch
73
74 import pytest
75
76 from muse.core._types import Manifest
77 from muse.plugins.code.ast_parser import SymbolRecord, SymbolTree
78 type SymbolMap = dict[str, SymbolRecord]
79 type SymbolsByFile = dict[str, SymbolMap]
80 from muse.plugins.code._callgraph import ForwardGraph
81
82
83 # ── Helpers ───────────────────────────────────────────────────────────────────
84
85
86 def _sym(
87 name: str,
88 content_id: str | None = None,
89 body_hash: str | None = None,
90 signature_id: str | None = None,
91 metadata_id: str = "",
92 ) -> SymbolRecord:
93 """Build a minimal SymbolRecord for testing."""
94 h = content_id or f"cid-{name}"
95 b = body_hash or f"bh-{name}"
96 s = signature_id or f"sid-{name}"
97 return {
98 "kind": "function",
99 "name": name,
100 "qualified_name": name,
101 "content_id": h,
102 "body_hash": b,
103 "signature_id": s,
104 "metadata_id": metadata_id,
105 "canonical_key": f"f.py##{name}",
106 }
107
108
109 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
110 muse_dir = tmp_path / ".muse"
111 muse_dir.mkdir()
112 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
113 (muse_dir / "repo.json").write_text(
114 json.dumps({"repo_id": str(uuid.uuid4()), "name": "test-repo"})
115 )
116 return tmp_path
117
118
119 @pytest.fixture()
120 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
121 return _make_repo(tmp_path)
122
123
124 def _mock_commit(cid: str | None = None) -> MagicMock:
125 m = MagicMock()
126 m.commit_id = cid or str(uuid.uuid4()).replace("-", "")
127 return m
128
129
130 def _run_plan_merge(
131 repo: pathlib.Path,
132 ours_ref: str = "HEAD",
133 theirs_ref: str = "main",
134 base_ref: str | None = None,
135 skip_call_graph: bool = True,
136 fmt: str = "json",
137 mock_ours_commit: MagicMock | None = None,
138 mock_theirs_commit: MagicMock | None = None,
139 mock_base_cid: str | None = None,
140 mock_ours_syms: SymbolMap | None = None,
141 mock_theirs_syms: SymbolMap | None = None,
142 mock_base_syms: SymbolMap | None = None,
143 ) -> tuple[int, str]:
144 """Run plan-merge with mocked commits/symbols. Returns (exit_code, stdout)."""
145 from muse.cli.commands.plan_merge import run as pm_run
146
147 ours_c = mock_ours_commit or _mock_commit("aaa" + "0" * 61)
148 theirs_c = mock_theirs_commit or _mock_commit("bbb" + "0" * 61)
149 base_cid = mock_base_cid or "ccc" + "0" * 61
150
151 ours_syms = mock_ours_syms or {}
152 theirs_syms = mock_theirs_syms or {}
153 base_syms = mock_base_syms or {}
154
155 def _sym_for_snapshot(root: pathlib.Path, manifest: Manifest, **kwargs: str) -> SymbolsByFile:
156 if manifest.get("_branch") == "ours":
157 return {"f.py": ours_syms}
158 if manifest.get("_branch") == "theirs":
159 return {"f.py": theirs_syms}
160 if manifest.get("_branch") == "base":
161 return {"f.py": base_syms}
162 return {}
163
164 ns = argparse.Namespace(
165 ours_ref=ours_ref,
166 theirs_ref=theirs_ref,
167 base_ref=base_ref,
168 skip_call_graph=skip_call_graph,
169 fmt=fmt,
170 )
171 old = os.getcwd()
172 os.chdir(repo)
173 import io, sys
174 captured = io.StringIO()
175 old_stdout = sys.stdout
176 sys.stdout = captured
177
178 exit_code = 0
179 try:
180 with (
181 patch("muse.cli.commands.plan_merge.require_repo", return_value=repo),
182 patch("muse.cli.commands.plan_merge.read_repo_id", return_value="test-repo"),
183 patch("muse.cli.commands.plan_merge.read_current_branch", return_value="main"),
184 patch("muse.cli.commands.plan_merge.resolve_commit_ref",
185 side_effect=lambda root, repo_id, branch, ref: (
186 ours_c if ref in (ours_ref, None) else
187 theirs_c if ref == theirs_ref else
188 _mock_commit(mock_base_cid) if ref == base_ref else None
189 )),
190 patch("muse.cli.commands.plan_merge.find_merge_base",
191 return_value=base_cid),
192 patch("muse.cli.commands.plan_merge.get_commit_snapshot_manifest",
193 side_effect=lambda root, cid: (
194 {"_branch": "ours"} if cid == ours_c.commit_id else
195 {"_branch": "theirs"} if cid == theirs_c.commit_id else
196 {"_branch": "base"}
197 )),
198 patch("muse.cli.commands.plan_merge.symbols_for_snapshot",
199 side_effect=_sym_for_snapshot),
200 ):
201 pm_run(ns)
202 except SystemExit as exc:
203 exit_code = exc.code or 0
204 finally:
205 sys.stdout = old_stdout
206 os.chdir(old)
207
208 return exit_code, captured.getvalue()
209
210
211 # ─────────────────────────────────────────────────────────────────────────────
212 # Unit tests — _classify_change
213 # ─────────────────────────────────────────────────────────────────────────────
214
215
216 class TestClassifyChange:
217 def test_unchanged(self) -> None:
218 from muse.cli.commands.plan_merge import _classify_change
219 s = _sym("fn", content_id="X")
220 assert _classify_change(s, s) == "unchanged"
221
222 def test_metadata_only(self) -> None:
223 from muse.cli.commands.plan_merge import _classify_change
224 base = _sym("fn", content_id="X", body_hash="BH", signature_id="SIG")
225 target = _sym("fn", content_id="Y", body_hash="BH", signature_id="SIG")
226 assert _classify_change(base, target) == "metadata_only"
227
228 def test_signature_only(self) -> None:
229 from muse.cli.commands.plan_merge import _classify_change
230 base = _sym("fn", body_hash="BH", signature_id="SIG1")
231 target = _sym("fn", content_id="X2", body_hash="BH", signature_id="SIG2")
232 assert _classify_change(base, target) == "signature_only"
233
234 def test_impl_only(self) -> None:
235 from muse.cli.commands.plan_merge import _classify_change
236 base = _sym("fn", body_hash="BH1", signature_id="SIG")
237 target = _sym("fn", content_id="X2", body_hash="BH2", signature_id="SIG")
238 assert _classify_change(base, target) == "impl_only"
239
240 def test_rename_modify(self) -> None:
241 from muse.cli.commands.plan_merge import _classify_change
242 base = {**_sym("fn_old"), "name": "fn_old"}
243 target = {**_sym("fn_new", content_id="X2", body_hash="BH2"), "name": "fn_new"}
244 assert _classify_change(base, target) == "rename+modify"
245
246 def test_full_rewrite(self) -> None:
247 from muse.cli.commands.plan_merge import _classify_change
248 base = _sym("fn")
249 target = _sym("fn", content_id="X2", body_hash="BH2", signature_id="SIG2")
250 assert _classify_change(base, target) == "full_rewrite"
251
252
253 # ─────────────────────────────────────────────────────────────────────────────
254 # Unit tests — _classify_conflict (three-way matrix)
255 # ─────────────────────────────────────────────────────────────────────────────
256
257
258 class TestClassifyConflict:
259 def test_both_absent_no_conflict(self) -> None:
260 from muse.cli.commands.plan_merge import _classify_conflict
261 item = _classify_conflict("x.py::fn", None, None, None)
262 assert item.conflict_type == "no_conflict"
263
264 def test_ours_new_no_base_no_conflict(self) -> None:
265 from muse.cli.commands.plan_merge import _classify_conflict
266 item = _classify_conflict("x.py::fn", None, _sym("fn"), None)
267 assert item.conflict_type == "no_conflict"
268 assert item.ours_change == "added"
269
270 def test_theirs_new_no_base_no_conflict(self) -> None:
271 from muse.cli.commands.plan_merge import _classify_conflict
272 item = _classify_conflict("x.py::fn", None, None, _sym("fn"))
273 assert item.conflict_type == "no_conflict"
274 assert item.theirs_change == "added"
275
276 def test_only_ours_changed_three_way(self) -> None:
277 """base=X, ours=Y, theirs=X → only ours changed → no_conflict."""
278 from muse.cli.commands.plan_merge import _classify_conflict
279 base = _sym("fn", content_id="X", body_hash="BH1", signature_id="SIG")
280 ours = _sym("fn", content_id="Y", body_hash="BH2", signature_id="SIG")
281 theirs = _sym("fn", content_id="X", body_hash="BH1", signature_id="SIG")
282 item = _classify_conflict("x.py::fn", base, ours, theirs)
283 assert item.conflict_type == "no_conflict"
284 assert "fast-forward" in item.recommendation
285
286 def test_only_theirs_changed_three_way(self) -> None:
287 """base=X, ours=X, theirs=Y → only theirs changed → no_conflict."""
288 from muse.cli.commands.plan_merge import _classify_conflict
289 base = _sym("fn", content_id="X", body_hash="BH1", signature_id="SIG")
290 ours = _sym("fn", content_id="X", body_hash="BH1", signature_id="SIG")
291 theirs = _sym("fn", content_id="Y", body_hash="BH2", signature_id="SIG")
292 item = _classify_conflict("x.py::fn", base, ours, theirs)
293 assert item.conflict_type == "no_conflict"
294
295 def test_both_changed_three_way_overlap(self) -> None:
296 """base=X, ours=Y, theirs=Z → both changed → symbol_edit_overlap."""
297 from muse.cli.commands.plan_merge import _classify_conflict
298 base = _sym("fn", content_id="X", body_hash="BH0", signature_id="SIG0")
299 ours = _sym("fn", content_id="Y", body_hash="BH1", signature_id="SIG0")
300 theirs = _sym("fn", content_id="Z", body_hash="BH2", signature_id="SIG0")
301 item = _classify_conflict("x.py::fn", base, ours, theirs)
302 assert item.conflict_type == "symbol_edit_overlap"
303
304 def test_identical_on_both_no_conflict(self) -> None:
305 from muse.cli.commands.plan_merge import _classify_conflict
306 s = _sym("fn", content_id="SAME")
307 item = _classify_conflict("x.py::fn", _sym("fn"), s, s)
308 assert item.conflict_type == "no_conflict"
309 assert "identical" in item.recommendation
310
311 def test_ours_deleted_theirs_unchanged_no_conflict(self) -> None:
312 """base=X, ours=None, theirs=X (unchanged) → no_conflict."""
313 from muse.cli.commands.plan_merge import _classify_conflict
314 base = _sym("fn", content_id="X", body_hash="BH", signature_id="SIG")
315 theirs = _sym("fn", content_id="X", body_hash="BH", signature_id="SIG")
316 item = _classify_conflict("x.py::fn", base, None, theirs)
317 assert item.conflict_type == "no_conflict"
318
319 def test_ours_deleted_theirs_modified_review(self) -> None:
320 """base=X, ours=None, theirs=Y → potentially delete_use → review."""
321 from muse.cli.commands.plan_merge import _classify_conflict
322 base = _sym("fn", content_id="X", body_hash="BH", signature_id="SIG")
323 theirs = _sym("fn", content_id="Y", body_hash="BH2", signature_id="SIG")
324 item = _classify_conflict("x.py::fn", base, None, theirs)
325 # delete_use is detected in Pass 3; this is a "review" no_conflict.
326 assert item.conflict_type == "no_conflict"
327 assert "review" in item.recommendation
328
329 def test_rename_edit_detected_ours_renamed(self) -> None:
330 """Ours renamed (same body, different name) + theirs modified → rename_edit."""
331 from muse.cli.commands.plan_merge import _classify_conflict
332 base_rec = {**_sym("fn_old", body_hash="BH", signature_id="SIG"), "name": "fn_old"}
333 # Ours: same body hash, but name changed (signature_only = rename)
334 ours_rec = {
335 **_sym("fn_new", content_id="C2", body_hash="BH", signature_id="SIG2"),
336 "name": "fn_new",
337 }
338 theirs_rec = {
339 **_sym("fn_old", content_id="C3", body_hash="BH3", signature_id="SIG"),
340 "name": "fn_old",
341 }
342 item = _classify_conflict("x.py::fn_old", base_rec, ours_rec, theirs_rec)
343 assert item.conflict_type == "rename_edit"
344
345 def test_both_added_same_content_no_conflict(self) -> None:
346 """No base, both added same symbol → no_conflict."""
347 from muse.cli.commands.plan_merge import _classify_conflict
348 s = _sym("fn", content_id="SAME")
349 item = _classify_conflict("x.py::fn", None, s, s)
350 assert item.conflict_type == "no_conflict"
351
352 def test_both_added_different_content_overlap(self) -> None:
353 """No base, both added different content → symbol_edit_overlap."""
354 from muse.cli.commands.plan_merge import _classify_conflict
355 item = _classify_conflict(
356 "x.py::fn", None,
357 _sym("fn", content_id="X"),
358 _sym("fn", content_id="Y"),
359 )
360 assert item.conflict_type == "symbol_edit_overlap"
361
362 def test_merge_item_slots_and_to_dict(self) -> None:
363 from muse.cli.commands.plan_merge import _MergeItem
364 m = _MergeItem("addr", "no_conflict", "a", "b", "rec")
365 d = m.to_dict()
366 assert set(d.keys()) == {"address", "conflict_type", "ours_change", "theirs_change", "recommendation"}
367 assert d["conflict_type"] == "no_conflict"
368
369
370 # ─────────────────────────────────────────────────────────────────────────────
371 # Unit tests — _find_renames_and_moves
372 # ─────────────────────────────────────────────────────────────────────────────
373
374
375 class TestFindRenamesAndMoves:
376 def test_rename_detected_same_file(self) -> None:
377 from muse.cli.commands.plan_merge import _find_renames_and_moves
378 base = {"file.py::fn_old": _sym("fn_old", body_hash="BODYHASH123456")}
379 branch = {"file.py::fn_new": {**_sym("fn_new", body_hash="BODYHASH123456"), "name": "fn_new"}}
380 renames, moves = _find_renames_and_moves(base, branch)
381 assert "file.py::fn_old" in renames
382 assert renames["file.py::fn_old"] == "file.py::fn_new"
383 assert not moves
384
385 def test_move_detected_different_file(self) -> None:
386 from muse.cli.commands.plan_merge import _find_renames_and_moves
387 base = {"old/file.py::fn": _sym("fn", body_hash="BODYHASH123456")}
388 branch = {"new/file.py::fn": _sym("fn", body_hash="BODYHASH123456")}
389 renames, moves = _find_renames_and_moves(base, branch)
390 assert not renames
391 assert "old/file.py::fn" in moves
392
393 def test_same_file_preferred_over_other_file(self) -> None:
394 from muse.cli.commands.plan_merge import _find_renames_and_moves
395 base = {"file.py::fn_old": _sym("fn_old", body_hash="BODYHASH123456")}
396 branch = {
397 "file.py::fn_new": _sym("fn_new", body_hash="BODYHASH123456"),
398 "other.py::fn_copy": _sym("fn_copy", body_hash="BODYHASH123456"),
399 }
400 renames, moves = _find_renames_and_moves(base, branch)
401 # Same file candidate should win → rename, not move.
402 assert "file.py::fn_old" in renames
403 assert "file.py::fn_old" not in moves
404
405 def test_trivial_body_hash_skipped(self) -> None:
406 """Very short body hashes are skipped to avoid false positives."""
407 from muse.cli.commands.plan_merge import _find_renames_and_moves
408 base = {"file.py::fn": _sym("fn", body_hash="X")} # Too short.
409 branch = {"file.py::fn_new": _sym("fn_new", body_hash="X")}
410 renames, moves = _find_renames_and_moves(base, branch)
411 assert not renames
412 assert not moves
413
414 def test_still_present_not_a_rename(self) -> None:
415 from muse.cli.commands.plan_merge import _find_renames_and_moves
416 sym = _sym("fn", body_hash="BODY1234567890")
417 base = {"file.py::fn": sym}
418 branch = {"file.py::fn": sym, "file.py::fn2": _sym("fn2", body_hash="BODY1234567890")}
419 renames, moves = _find_renames_and_moves(base, branch)
420 # Original still present → not a rename.
421 assert "file.py::fn" not in renames
422
423
424 # ─────────────────────────────────────────────────────────────────────────────
425 # Unit tests — _find_delete_use_conflicts
426 # ─────────────────────────────────────────────────────────────────────────────
427
428
429 class TestFindDeleteUseConflicts:
430 def _make_manifests(self) -> tuple[Manifest, Manifest, Manifest]:
431 return {"base": "m"}, {"ours": "m"}, {"theirs": "m"}
432
433 def test_delete_use_detected(self, repo: pathlib.Path) -> None:
434 from muse.cli.commands.plan_merge import _find_delete_use_conflicts
435 base_syms = {"src/api.py::fn": _sym("fn")}
436 ours_syms = {} # deleted on ours
437 theirs_syms = {"src/api.py::fn": _sym("fn")} # still in theirs
438
439 # Theirs has a new caller of fn that wasn't in base.
440 base_fg = {"caller_base.py::existing": frozenset()}
441 ours_fg = {}
442 theirs_fg = {
443 "caller_new.py::new_caller": frozenset({"fn"}), # new!
444 }
445 with (
446 patch("muse.plugins.code._callgraph.build_forward_graph",
447 side_effect=[base_fg, ours_fg, theirs_fg]),
448 ):
449 items, ok, warn = _find_delete_use_conflicts(
450 repo, {}, {}, {}, base_syms, ours_syms, theirs_syms,
451 )
452
453 assert ok is True
454 assert warn is None
455 assert len(items) == 1
456 assert items[0].conflict_type == "delete_use"
457 assert "caller_new.py::new_caller" in items[0].theirs_change
458
459 def test_no_delete_use_when_no_deletions(self, repo: pathlib.Path) -> None:
460 from muse.cli.commands.plan_merge import _find_delete_use_conflicts
461 base_syms = {"src/api.py::fn": _sym("fn")}
462 ours_syms = {"src/api.py::fn": _sym("fn")}
463 theirs_syms = {"src/api.py::fn": _sym("fn")}
464 items, ok, warn = _find_delete_use_conflicts(
465 repo, {}, {}, {}, base_syms, ours_syms, theirs_syms,
466 )
467 assert ok is True
468 assert items == []
469
470 def test_call_graph_unavailable_warns(self, repo: pathlib.Path) -> None:
471 from muse.cli.commands.plan_merge import _find_delete_use_conflicts
472 base_syms = {"src/api.py::fn": _sym("fn")}
473 ours_syms = {}
474 theirs_syms = {"src/api.py::fn": _sym("fn")}
475
476 with patch("muse.plugins.code._callgraph.build_forward_graph",
477 side_effect=OSError("no index")):
478 items, ok, warn = _find_delete_use_conflicts(
479 repo, {}, {}, {}, base_syms, ours_syms, theirs_syms,
480 )
481
482 assert ok is False
483 assert warn is not None
484 assert "call graph unavailable" in warn
485 assert items == []
486
487 def test_keyerror_from_call_graph_warns(self, repo: pathlib.Path) -> None:
488 from muse.cli.commands.plan_merge import _find_delete_use_conflicts
489 base_syms = {"src/api.py::fn": _sym("fn")}
490 ours_syms = {}
491 theirs_syms = {"src/api.py::fn": _sym("fn")}
492
493 with patch("muse.plugins.code._callgraph.build_forward_graph",
494 side_effect=KeyError("missing")):
495 items, ok, warn = _find_delete_use_conflicts(
496 repo, {}, {}, {}, base_syms, ours_syms, theirs_syms,
497 )
498
499 assert ok is False
500
501 def test_unexpected_exception_propagates(self, repo: pathlib.Path) -> None:
502 from muse.cli.commands.plan_merge import _find_delete_use_conflicts
503 base_syms = {"src/api.py::fn": _sym("fn")}
504 ours_syms = {}
505 theirs_syms = {"src/api.py::fn": _sym("fn")}
506
507 with patch("muse.plugins.code._callgraph.build_forward_graph",
508 side_effect=MemoryError("OOM")):
509 with pytest.raises(MemoryError):
510 _find_delete_use_conflicts(
511 repo, {}, {}, {}, base_syms, ours_syms, theirs_syms,
512 )
513
514 def test_caller_preview_truncated_at_3(self, repo: pathlib.Path) -> None:
515 from muse.cli.commands.plan_merge import _find_delete_use_conflicts
516 base_syms = {"src/api.py::fn": _sym("fn")}
517 ours_syms = {}
518 theirs_syms = {"src/api.py::fn": _sym("fn")}
519
520 theirs_fg = {f"mod{i}.py::caller{i}": frozenset({"fn"}) for i in range(10)}
521 base_fg = {}
522 ours_fg = {}
523
524 with patch("muse.plugins.code._callgraph.build_forward_graph",
525 side_effect=[base_fg, ours_fg, theirs_fg]):
526 items, ok, _ = _find_delete_use_conflicts(
527 repo, {}, {}, {}, base_syms, ours_syms, theirs_syms,
528 )
529
530 assert len(items) == 1
531 assert "+7 more" in items[0].theirs_change
532
533
534 # ─────────────────────────────────────────────────────────────────────────────
535 # Unit tests — _find_dependency_conflicts
536 # ─────────────────────────────────────────────────────────────────────────────
537
538
539 class TestFindDependencyConflicts:
540 def test_dependency_detected(self, repo: pathlib.Path) -> None:
541 from muse.cli.commands.plan_merge import _find_dependency_conflicts
542 # Ours changed fn_a; theirs changed fn_b which calls fn_a.
543 ours_changed = {"src/x.py::fn_a"}
544 theirs_changed = {"src/y.py::fn_b"}
545 reverse = {"fn_a": ["src/y.py::fn_b"]} # fn_b calls fn_a.
546
547 with (
548 patch("muse.plugins.code._callgraph.build_reverse_graph", return_value=reverse),
549 patch("muse.plugins.code._callgraph.transitive_callers",
550 return_value={1: ["src/y.py::fn_b"]}),
551 ):
552 items, ok, warn = _find_dependency_conflicts(
553 repo, {}, {}, ours_changed, theirs_changed,
554 )
555
556 assert ok is True
557 assert len(items) == 1
558 assert items[0].conflict_type == "dependency_conflict"
559 assert "src/x.py::fn_a" == items[0].address
560
561 def test_no_conflict_when_no_changes(self, repo: pathlib.Path) -> None:
562 from muse.cli.commands.plan_merge import _find_dependency_conflicts
563 items, ok, warn = _find_dependency_conflicts(repo, {}, {}, set(), set())
564 assert items == []
565 assert ok is True
566
567 def test_call_graph_error_warns(self, repo: pathlib.Path) -> None:
568 from muse.cli.commands.plan_merge import _find_dependency_conflicts
569 with patch("muse.plugins.code._callgraph.build_reverse_graph",
570 side_effect=ValueError("bad data")):
571 items, ok, warn = _find_dependency_conflicts(
572 repo, {}, {}, {"x.py::fn"}, {"y.py::fn"},
573 )
574 assert ok is False
575 assert warn is not None
576
577 def test_deduplication(self, repo: pathlib.Path) -> None:
578 """Same (ours_addr, theirs_addr) pair not added twice."""
579 from muse.cli.commands.plan_merge import _find_dependency_conflicts
580 ours_changed = {"x.py::fn_a"}
581 theirs_changed = {"y.py::fn_b"}
582 reverse = {"fn_a": ["y.py::fn_b", "y.py::fn_b"]} # Duplicate.
583
584 with (
585 patch("muse.plugins.code._callgraph.build_reverse_graph", return_value=reverse),
586 patch("muse.plugins.code._callgraph.transitive_callers",
587 return_value={1: ["y.py::fn_b", "y.py::fn_b"]}),
588 ):
589 items, _, _ = _find_dependency_conflicts(repo, {}, {}, ours_changed, theirs_changed)
590
591 assert len(items) == 1 # Not duplicated.
592
593
594 # ─────────────────────────────────────────────────────────────────────────────
595 # Integration tests — full CLI (mock-based)
596 # ─────────────────────────────────────────────────────────────────────────────
597
598
599 class TestPlanMergeIntegration:
600 def test_ref_not_found_exits_1(self, repo: pathlib.Path) -> None:
601 from muse.cli.commands.plan_merge import run as pm_run
602 ns = argparse.Namespace(
603 ours_ref="nonexistent", theirs_ref="main",
604 base_ref=None, skip_call_graph=True, fmt="json",
605 )
606 old = os.getcwd()
607 os.chdir(repo)
608 try:
609 with (
610 patch("muse.cli.commands.plan_merge.require_repo", return_value=repo),
611 patch("muse.cli.commands.plan_merge.read_repo_id", return_value="r"),
612 patch("muse.cli.commands.plan_merge.read_current_branch", return_value="main"),
613 patch("muse.cli.commands.plan_merge.resolve_commit_ref", return_value=None),
614 ):
615 with pytest.raises(SystemExit) as exc:
616 pm_run(ns)
617 finally:
618 os.chdir(old)
619 assert exc.value.code == 1
620
621 def test_json_schema_complete(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
622 code, out = _run_plan_merge(repo)
623 data = json.loads(out)
624 required = {
625 "schema_version", "ours", "theirs", "base", "base_auto_computed",
626 "call_graph_available", "call_graph_skipped", "warnings",
627 "total_symbols", "conflicts", "clean", "items", "elapsed_seconds",
628 }
629 assert required.issubset(data.keys())
630
631 def test_elapsed_seconds_is_non_negative_float(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
632 _, out = _run_plan_merge(repo)
633 data = json.loads(out)
634 assert isinstance(data["elapsed_seconds"], float)
635 assert data["elapsed_seconds"] >= 0
636
637 def test_full_commit_ids_in_json(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
638 ours_c = _mock_commit("a" * 64)
639 theirs_c = _mock_commit("b" * 64)
640 _, out = _run_plan_merge(
641 repo,
642 mock_ours_commit=ours_c,
643 mock_theirs_commit=theirs_c,
644 )
645 data = json.loads(out)
646 assert data["ours"] == "a" * 64
647 assert data["theirs"] == "b" * 64
648
649 def test_base_auto_computed_true_when_no_base_arg(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
650 _, out = _run_plan_merge(repo)
651 data = json.loads(out)
652 assert data["base_auto_computed"] is True
653
654 def test_base_auto_computed_false_when_base_arg_given(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
655 from muse.cli.commands.plan_merge import run as pm_run
656 ours_c = _mock_commit("a" * 64)
657 theirs_c = _mock_commit("b" * 64)
658 base_c = _mock_commit("c" * 64)
659
660 ns = argparse.Namespace(
661 ours_ref="HEAD", theirs_ref="main",
662 base_ref="base-ref", skip_call_graph=True, fmt="json",
663 )
664 import io, sys
665 captured = io.StringIO()
666 old_stdout = sys.stdout
667 sys.stdout = captured
668 old = os.getcwd()
669 os.chdir(repo)
670 try:
671 with (
672 patch("muse.cli.commands.plan_merge.require_repo", return_value=repo),
673 patch("muse.cli.commands.plan_merge.read_repo_id", return_value="r"),
674 patch("muse.cli.commands.plan_merge.read_current_branch", return_value="main"),
675 patch("muse.cli.commands.plan_merge.resolve_commit_ref",
676 side_effect=lambda *a, **kw: (
677 ours_c if a[3] in ("HEAD", None) else
678 theirs_c if a[3] == "main" else
679 base_c if a[3] == "base-ref" else None
680 )),
681 patch("muse.cli.commands.plan_merge.find_merge_base", return_value="c" * 64),
682 patch("muse.cli.commands.plan_merge.get_commit_snapshot_manifest", return_value={}),
683 patch("muse.cli.commands.plan_merge.symbols_for_snapshot", return_value={}),
684 ):
685 pm_run(ns)
686 except SystemExit:
687 pass
688 finally:
689 sys.stdout = old_stdout
690 os.chdir(old)
691
692 data = json.loads(captured.getvalue())
693 assert data["base_auto_computed"] is False
694
695 def test_call_graph_skipped_flag(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
696 _, out = _run_plan_merge(repo, skip_call_graph=True)
697 data = json.loads(out)
698 assert data["call_graph_skipped"] is True
699
700 def test_warnings_list_in_json(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
701 _, out = _run_plan_merge(repo)
702 data = json.loads(out)
703 assert isinstance(data["warnings"], list)
704
705 def test_no_conflicts_empty_swarm(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
706 _, out = _run_plan_merge(repo)
707 data = json.loads(out)
708 assert data["conflicts"] == 0
709 assert data["items"] == []
710
711 def test_symbol_edit_overlap_detected(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
712 """Three-way: base=X, ours=Y, theirs=Z → symbol_edit_overlap."""
713 base = {"f.py::fn": _sym("fn", content_id="X", body_hash="BH0", signature_id="SIG")}
714 ours = {"f.py::fn": _sym("fn", content_id="Y", body_hash="BH1", signature_id="SIG")}
715 theirs = {"f.py::fn": _sym("fn", content_id="Z", body_hash="BH2", signature_id="SIG")}
716 _, out = _run_plan_merge(
717 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
718 )
719 data = json.loads(out)
720 assert data["conflicts"] == 1
721 assert data["items"][0]["conflict_type"] == "symbol_edit_overlap"
722
723 def test_no_false_positive_unilateral_change(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
724 """Three-way: base=X, ours=Y, theirs=X → only ours changed → no_conflict."""
725 base = {"f.py::fn": _sym("fn", content_id="X", body_hash="BH0", signature_id="SIG")}
726 ours = {"f.py::fn": _sym("fn", content_id="Y", body_hash="BH1", signature_id="SIG")}
727 theirs = {"f.py::fn": _sym("fn", content_id="X", body_hash="BH0", signature_id="SIG")}
728 _, out = _run_plan_merge(
729 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
730 )
731 data = json.loads(out)
732 assert data["conflicts"] == 0, (
733 "Three-way should detect no conflict when only ours changed"
734 )
735
736 def test_rename_edit_via_pass2_body_hash(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
737 """Pass 2 upgrades symbol_edit_overlap → rename_edit via body_hash matching."""
738 body = "REAL_BODY_HASH_12345"
739 base = {"f.py::fn_old": {**_sym("fn_old", content_id="X", body_hash=body), "name": "fn_old"}}
740 # Ours: fn_old deleted, fn_new added with same body (rename)
741 ours = {"f.py::fn_new": {**_sym("fn_new", content_id="Y", body_hash=body), "name": "fn_new"}}
742 # Theirs: fn_old modified (impl change)
743 theirs = {"f.py::fn_old": {**_sym("fn_old", content_id="Z", body_hash="OTHER"), "name": "fn_old"}}
744 _, out = _run_plan_merge(
745 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
746 )
747 data = json.loads(out)
748 conflict_types = [i["conflict_type"] for i in data["items"]]
749 assert "rename_edit" in conflict_types
750
751 def test_move_edit_via_pass2(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
752 """Pass 2 detects move_edit when ours moved to different file and theirs modified."""
753 body = "MOVED_BODY_HASH_12345"
754 base = {"old/file.py::fn": _sym("fn", content_id="X", body_hash=body)}
755 # Ours: fn moved to new/file.py
756 ours = {"new/file.py::fn": _sym("fn", content_id="X", body_hash=body)}
757 # Theirs: fn modified in original location
758 theirs = {"old/file.py::fn": _sym("fn", content_id="Z", body_hash="OTHER")}
759 _, out = _run_plan_merge(
760 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
761 )
762 data = json.loads(out)
763 conflict_types = [i["conflict_type"] for i in data["items"]]
764 assert "move_edit" in conflict_types
765
766 def test_text_output_format(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
767 _, out = _run_plan_merge(repo, fmt="text")
768 assert "Semantic merge plan" in out
769 assert "base:" in out
770
771 def test_text_output_shows_conflicts(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
772 base = {"f.py::fn": _sym("fn", content_id="X", body_hash="BH0", signature_id="SIG")}
773 ours = {"f.py::fn": _sym("fn", content_id="Y", body_hash="BH1", signature_id="SIG")}
774 theirs = {"f.py::fn": _sym("fn", content_id="Z", body_hash="BH2", signature_id="SIG")}
775 _, out = _run_plan_merge(
776 repo, fmt="text",
777 mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
778 )
779 assert "symbol_edit_overlap" in out
780 assert "ours:" in out
781 assert "theirs:" in out
782
783 def test_text_output_shows_elapsed(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
784 _, out = _run_plan_merge(repo, fmt="text")
785 assert "s)" in out
786
787 def test_skip_call_graph_omits_delete_use(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
788 base = {"f.py::fn": _sym("fn")}
789 ours = {} # deleted
790 theirs = {"f.py::fn": _sym("fn")}
791 _, out = _run_plan_merge(
792 repo, skip_call_graph=True,
793 mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
794 )
795 data = json.loads(out)
796 conflict_types = [i["conflict_type"] for i in data["items"]]
797 assert "delete_use" not in conflict_types
798
799 def test_base_none_warning_in_json(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
800 """When find_merge_base returns None, warnings includes a notice."""
801 from muse.cli.commands.plan_merge import run as pm_run
802 ours_c = _mock_commit("a" * 64)
803 theirs_c = _mock_commit("b" * 64)
804 ns = argparse.Namespace(
805 ours_ref="HEAD", theirs_ref="main",
806 base_ref=None, skip_call_graph=True, fmt="json",
807 )
808 import io, sys
809 captured = io.StringIO()
810 old = os.getcwd()
811 os.chdir(repo)
812 sys.stdout = captured
813 try:
814 with (
815 patch("muse.cli.commands.plan_merge.require_repo", return_value=repo),
816 patch("muse.cli.commands.plan_merge.read_repo_id", return_value="r"),
817 patch("muse.cli.commands.plan_merge.read_current_branch", return_value="main"),
818 patch("muse.cli.commands.plan_merge.resolve_commit_ref",
819 side_effect=lambda *a: ours_c if a[3] in ("HEAD", None) else theirs_c),
820 patch("muse.cli.commands.plan_merge.find_merge_base", return_value=None),
821 patch("muse.cli.commands.plan_merge.get_commit_snapshot_manifest", return_value={}),
822 patch("muse.cli.commands.plan_merge.symbols_for_snapshot", return_value={}),
823 ):
824 pm_run(ns)
825 except SystemExit:
826 pass
827 finally:
828 sys.stdout = sys.__stdout__
829 os.chdir(old)
830
831 data = json.loads(captured.getvalue())
832 assert any("no common ancestor" in w for w in data["warnings"])
833 assert data["base"] is None
834
835 def test_format_json_shorthand(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
836 """--json is equivalent to --format json."""
837 from muse.cli.commands.plan_merge import run as pm_run
838 ours_c = _mock_commit("a" * 64)
839 theirs_c = _mock_commit("b" * 64)
840 ns = argparse.Namespace(
841 ours_ref="HEAD", theirs_ref="main",
842 base_ref=None, skip_call_graph=True, fmt="json", # same as --json
843 )
844 import io, sys
845 captured = io.StringIO()
846 old = os.getcwd()
847 os.chdir(repo)
848 sys.stdout = captured
849 try:
850 with (
851 patch("muse.cli.commands.plan_merge.require_repo", return_value=repo),
852 patch("muse.cli.commands.plan_merge.read_repo_id", return_value="r"),
853 patch("muse.cli.commands.plan_merge.read_current_branch", return_value="main"),
854 patch("muse.cli.commands.plan_merge.resolve_commit_ref",
855 side_effect=lambda *a: ours_c if a[3] in ("HEAD", None) else theirs_c),
856 patch("muse.cli.commands.plan_merge.find_merge_base", return_value="c" * 64),
857 patch("muse.cli.commands.plan_merge.get_commit_snapshot_manifest", return_value={}),
858 patch("muse.cli.commands.plan_merge.symbols_for_snapshot", return_value={}),
859 ):
860 pm_run(ns)
861 except SystemExit:
862 pass
863 finally:
864 sys.stdout = sys.__stdout__
865 os.chdir(old)
866
867 data = json.loads(captured.getvalue())
868 assert "conflicts" in data
869
870
871 # ─────────────────────────────────────────────────────────────────────────────
872 # Security tests
873 # ─────────────────────────────────────────────────────────────────────────────
874
875
876 class TestPlanMergeSecurity:
877 def test_ansi_in_address_stripped_text_output(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
878 """ANSI escape codes in symbol addresses are stripped before display."""
879 ansi_addr = "\x1b[31msrc/evil.py::fn\x1b[0m"
880 base = {ansi_addr: _sym("fn", content_id="X", body_hash="BH0", signature_id="SIG")}
881 ours = {ansi_addr: _sym("fn", content_id="Y", body_hash="BH1", signature_id="SIG")}
882 theirs = {ansi_addr: _sym("fn", content_id="Z", body_hash="BH2", signature_id="SIG")}
883 _, out = _run_plan_merge(
884 repo, fmt="text",
885 mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
886 )
887 assert "\x1b[" not in out
888 assert "src/evil.py::fn" in out # sanitized content still shown
889
890 def test_ansi_in_recommendation_stripped(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
891 base = {"f.py::fn": _sym("fn", content_id="X", body_hash="BH0", signature_id="SIG")}
892 ours = {"f.py::fn": _sym("fn", content_id="Y", body_hash="BH1", signature_id="SIG")}
893 theirs = {"f.py::fn": _sym("fn", content_id="Z", body_hash="BH2", signature_id="SIG")}
894 _, out = _run_plan_merge(
895 repo, fmt="text",
896 mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
897 )
898 assert "\x1b[" not in out
899
900 def test_control_chars_in_ref_not_escape_fs(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
901 """Control characters in ref names are sanitised before display in error output."""
902 from muse.cli.commands.plan_merge import run as pm_run
903 import io, sys
904 captured = io.StringIO()
905 evil_ref = "ref\x00/../../../etc"
906 ns = argparse.Namespace(
907 ours_ref=evil_ref, theirs_ref="main",
908 base_ref=None, skip_call_graph=True, fmt="json",
909 )
910 old = os.getcwd()
911 os.chdir(repo)
912 sys.stdout = captured
913 try:
914 with (
915 patch("muse.cli.commands.plan_merge.require_repo", return_value=repo),
916 patch("muse.cli.commands.plan_merge.read_repo_id", return_value="r"),
917 patch("muse.cli.commands.plan_merge.read_current_branch", return_value="main"),
918 patch("muse.cli.commands.plan_merge.resolve_commit_ref", return_value=None),
919 ):
920 with pytest.raises(SystemExit) as exc:
921 pm_run(ns)
922 finally:
923 sys.stdout = sys.__stdout__
924 os.chdir(old)
925 assert exc.value.code == 1
926 out = captured.getvalue()
927 # Output must not contain raw null bytes.
928 assert "\x00" not in out
929
930
931 # ─────────────────────────────────────────────────────────────────────────────
932 # Stress tests
933 # ─────────────────────────────────────────────────────────────────────────────
934
935
936 class TestPlanMergeStress:
937 def test_1000_symbols_pass1_under_2s(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
938 """Pass 1 with 1000 symbols completes in < 2 s."""
939 N = 1000
940 base = {f"f{i}.py::fn{i}": _sym(f"fn{i}", content_id=f"X{i}") for i in range(N)}
941 # Ours and theirs both change half the symbols differently.
942 ours = {
943 f"f{i}.py::fn{i}": (
944 _sym(f"fn{i}", content_id=f"Y{i}") if i % 2 == 0
945 else _sym(f"fn{i}", content_id=f"X{i}")
946 )
947 for i in range(N)
948 }
949 theirs = {
950 f"f{i}.py::fn{i}": (
951 _sym(f"fn{i}", content_id=f"X{i}") if i % 2 == 0
952 else _sym(f"fn{i}", content_id=f"Z{i}")
953 )
954 for i in range(N)
955 }
956
957 t0 = time.monotonic()
958 _, out = _run_plan_merge(
959 repo,
960 mock_ours_syms=ours,
961 mock_theirs_syms=theirs,
962 mock_base_syms=base,
963 )
964 elapsed = time.monotonic() - t0
965
966 assert elapsed < 2.0, f"Pass 1 took {elapsed:.2f}s — too slow"
967 data = json.loads(out)
968 assert data["total_symbols"] == N
969
970 def test_200_renames_pass2_under_1s(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
971 """Pass 2 rename detection with 200 renames completes in < 1 s."""
972 N = 200
973 body_hashes = [f"BODYHASH{i:08d}" for i in range(N)]
974 base = {
975 f"f.py::fn_old_{i}": {**_sym(f"fn_old_{i}", body_hash=body_hashes[i]), "name": f"fn_old_{i}"}
976 for i in range(N)
977 }
978 # Ours: all renamed
979 ours = {
980 f"f.py::fn_new_{i}": {**_sym(f"fn_new_{i}", body_hash=body_hashes[i]), "name": f"fn_new_{i}"}
981 for i in range(N)
982 }
983 # Theirs: all modified (different body)
984 theirs = {
985 f"f.py::fn_old_{i}": {**_sym(f"fn_old_{i}", content_id=f"Z{i}", body_hash=f"DIFF{i}"), "name": f"fn_old_{i}"}
986 for i in range(N)
987 }
988
989 t0 = time.monotonic()
990 _, out = _run_plan_merge(
991 repo,
992 mock_ours_syms=ours,
993 mock_theirs_syms=theirs,
994 mock_base_syms=base,
995 )
996 elapsed = time.monotonic() - t0
997
998 assert elapsed < 1.0, f"Pass 2 took {elapsed:.2f}s — too slow"
999 data = json.loads(out)
1000 conflict_types = [i["conflict_type"] for i in data["items"]]
1001 assert "rename_edit" in conflict_types
1002
1003 def test_100_deletes_delete_use_detection_under_2s(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1004 """delete_use detection with 100 deleted symbols completes in < 2 s."""
1005 from muse.cli.commands.plan_merge import _find_delete_use_conflicts
1006
1007 N = 100
1008 base_syms = {f"f{i}.py::fn{i}": _sym(f"fn{i}") for i in range(N)}
1009 ours_syms: SymbolTree = {} # All deleted on ours.
1010 theirs_syms = dict(base_syms) # All present on theirs.
1011
1012 # Mock: each fn has a new caller on theirs.
1013 base_fg: ForwardGraph = {}
1014 ours_fg: ForwardGraph = {}
1015 theirs_fg = {f"new_caller{i}.py::caller{i}": frozenset({f"fn{i}"}) for i in range(N)}
1016
1017 t0 = time.monotonic()
1018 with patch("muse.plugins.code._callgraph.build_forward_graph",
1019 side_effect=[base_fg, ours_fg, theirs_fg]):
1020 items, ok, warn = _find_delete_use_conflicts(
1021 repo, {}, {}, {}, base_syms, ours_syms, theirs_syms,
1022 )
1023 elapsed = time.monotonic() - t0
1024
1025 assert elapsed < 2.0, f"delete_use took {elapsed:.2f}s — too slow"
1026 assert ok is True
1027 assert len(items) == N
1028
1029 def test_correctness_three_way_no_false_positives(self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1030 """Regression: three-way diff must not produce false positives on unilateral changes."""
1031 N = 500
1032 # Ours changes even-indexed symbols, theirs changes odd-indexed.
1033 # Each symbol is changed by only ONE side → all should be no_conflict.
1034 base = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"BASE{i}") for i in range(N)}
1035 ours = {
1036 f"f.py::fn{i}": (
1037 _sym(f"fn{i}", content_id=f"OUR{i}") if i % 2 == 0
1038 else _sym(f"fn{i}", content_id=f"BASE{i}")
1039 )
1040 for i in range(N)
1041 }
1042 theirs = {
1043 f"f.py::fn{i}": (
1044 _sym(f"fn{i}", content_id=f"BASE{i}") if i % 2 == 0
1045 else _sym(f"fn{i}", content_id=f"THEIR{i}")
1046 )
1047 for i in range(N)
1048 }
1049
1050 _, out = _run_plan_merge(
1051 repo,
1052 mock_ours_syms=ours,
1053 mock_theirs_syms=theirs,
1054 mock_base_syms=base,
1055 )
1056 data = json.loads(out)
1057 assert data["conflicts"] == 0, (
1058 f"Three-way correctness: expected 0 conflicts, got {data['conflicts']}"
1059 )
1060
1061
1062 # ─────────────────────────────────────────────────────────────────────────────
1063 # Error shape tests
1064 # ─────────────────────────────────────────────────────────────────────────────
1065
1066
1067 class TestPlanMergeErrorShapes:
1068 """Verify error output shapes are consistent across text and JSON modes."""
1069
1070 def _run_with_no_ours(self, repo: pathlib.Path, fmt: str = "json") -> tuple[int, str, str]:
1071 """Run plan-merge where ours-ref resolves to None."""
1072 from muse.cli.commands.plan_merge import run as pm_run
1073 ns = argparse.Namespace(
1074 ours_ref="missing-ref", theirs_ref="main",
1075 base_ref=None, skip_call_graph=True, fmt=fmt,
1076 )
1077 import io
1078 captured = io.StringIO()
1079 old_stdout = sys.stdout
1080 old_stderr = sys.stderr
1081 sys.stdout = captured
1082 err_captured = io.StringIO()
1083 sys.stderr = err_captured
1084 old = os.getcwd()
1085 os.chdir(repo)
1086 exit_code = 0
1087 try:
1088 with (
1089 patch("muse.cli.commands.plan_merge.require_repo", return_value=repo),
1090 patch("muse.cli.commands.plan_merge.read_repo_id", return_value="r"),
1091 patch("muse.cli.commands.plan_merge.read_current_branch", return_value="main"),
1092 patch("muse.cli.commands.plan_merge.resolve_commit_ref", return_value=None),
1093 ):
1094 with pytest.raises(SystemExit) as exc:
1095 pm_run(ns)
1096 exit_code = exc.value.code
1097 finally:
1098 sys.stdout = old_stdout
1099 sys.stderr = old_stderr
1100 os.chdir(old)
1101 return exit_code, captured.getvalue(), err_captured.getvalue()
1102
1103 def _run_with_no_theirs(self, repo: pathlib.Path, fmt: str = "json") -> tuple[int, str, str]:
1104 from muse.cli.commands.plan_merge import run as pm_run
1105 ours_c = _mock_commit("a" * 64)
1106 ns = argparse.Namespace(
1107 ours_ref="HEAD", theirs_ref="missing-branch",
1108 base_ref=None, skip_call_graph=True, fmt=fmt,
1109 )
1110 import io
1111 captured = io.StringIO()
1112 err_captured = io.StringIO()
1113 old_stdout, old_stderr = sys.stdout, sys.stderr
1114 sys.stdout = captured
1115 sys.stderr = err_captured
1116 old = os.getcwd()
1117 os.chdir(repo)
1118 try:
1119 with (
1120 patch("muse.cli.commands.plan_merge.require_repo", return_value=repo),
1121 patch("muse.cli.commands.plan_merge.read_repo_id", return_value="r"),
1122 patch("muse.cli.commands.plan_merge.read_current_branch", return_value="main"),
1123 patch("muse.cli.commands.plan_merge.resolve_commit_ref",
1124 side_effect=lambda *a: ours_c if a[3] == "HEAD" else None),
1125 ):
1126 with pytest.raises(SystemExit) as exc:
1127 pm_run(ns)
1128 exit_code = exc.value.code
1129 finally:
1130 sys.stdout = old_stdout
1131 sys.stderr = old_stderr
1132 os.chdir(old)
1133 return exit_code, captured.getvalue(), err_captured.getvalue()
1134
1135 def _run_with_no_base(self, repo: pathlib.Path, fmt: str = "json") -> tuple[int, str, str]:
1136 from muse.cli.commands.plan_merge import run as pm_run
1137 ours_c = _mock_commit("a" * 64)
1138 theirs_c = _mock_commit("b" * 64)
1139 ns = argparse.Namespace(
1140 ours_ref="HEAD", theirs_ref="main",
1141 base_ref="missing-base", skip_call_graph=True, fmt=fmt,
1142 )
1143 import io
1144 captured = io.StringIO()
1145 err_captured = io.StringIO()
1146 old_stdout, old_stderr = sys.stdout, sys.stderr
1147 sys.stdout = captured
1148 sys.stderr = err_captured
1149 old = os.getcwd()
1150 os.chdir(repo)
1151 try:
1152 with (
1153 patch("muse.cli.commands.plan_merge.require_repo", return_value=repo),
1154 patch("muse.cli.commands.plan_merge.read_repo_id", return_value="r"),
1155 patch("muse.cli.commands.plan_merge.read_current_branch", return_value="main"),
1156 patch("muse.cli.commands.plan_merge.resolve_commit_ref",
1157 side_effect=lambda *a: (
1158 ours_c if a[3] == "HEAD" else
1159 theirs_c if a[3] == "main" else
1160 None # base-ref not found
1161 )),
1162 ):
1163 with pytest.raises(SystemExit) as exc:
1164 pm_run(ns)
1165 exit_code = exc.value.code
1166 finally:
1167 sys.stdout = old_stdout
1168 sys.stderr = old_stderr
1169 os.chdir(old)
1170 return exit_code, captured.getvalue(), err_captured.getvalue()
1171
1172 # ── ours-ref not found ────────────────────────────────────────────────────
1173
1174 def test_ours_not_found_json_has_error_and_status(self, repo: pathlib.Path) -> None:
1175 code, out, _ = self._run_with_no_ours(repo, fmt="json")
1176 assert code == 1
1177 data = json.loads(out.strip())
1178 assert "error" in data
1179 assert data["status"] == "error"
1180
1181 def test_ours_not_found_json_error_mentions_ref(self, repo: pathlib.Path) -> None:
1182 code, out, _ = self._run_with_no_ours(repo, fmt="json")
1183 data = json.loads(out.strip())
1184 assert "missing-ref" in data["error"]
1185
1186 def test_ours_not_found_text_uses_tick_prefix(self, repo: pathlib.Path) -> None:
1187 code, _, err = self._run_with_no_ours(repo, fmt="text")
1188 assert code == 1
1189 assert "❌" in err
1190
1191 def test_ours_not_found_text_no_output_on_stdout(self, repo: pathlib.Path) -> None:
1192 code, out, _ = self._run_with_no_ours(repo, fmt="text")
1193 assert out == ""
1194
1195 # ── theirs-ref not found ──────────────────────────────────────────────────
1196
1197 def test_theirs_not_found_exits_1(self, repo: pathlib.Path) -> None:
1198 code, _, _ = self._run_with_no_theirs(repo)
1199 assert code == 1
1200
1201 def test_theirs_not_found_json_has_status(self, repo: pathlib.Path) -> None:
1202 code, out, _ = self._run_with_no_theirs(repo, fmt="json")
1203 data = json.loads(out.strip())
1204 assert data["status"] == "error"
1205
1206 def test_theirs_not_found_error_mentions_ref(self, repo: pathlib.Path) -> None:
1207 code, out, _ = self._run_with_no_theirs(repo, fmt="json")
1208 data = json.loads(out.strip())
1209 assert "missing-branch" in data["error"]
1210
1211 def test_theirs_not_found_text_uses_tick_prefix(self, repo: pathlib.Path) -> None:
1212 code, _, err = self._run_with_no_theirs(repo, fmt="text")
1213 assert "❌" in err
1214
1215 # ── base-ref not found ────────────────────────────────────────────────────
1216
1217 def test_base_not_found_exits_1(self, repo: pathlib.Path) -> None:
1218 code, _, _ = self._run_with_no_base(repo)
1219 assert code == 1
1220
1221 def test_base_not_found_json_has_status(self, repo: pathlib.Path) -> None:
1222 code, out, _ = self._run_with_no_base(repo, fmt="json")
1223 data = json.loads(out.strip())
1224 assert data["status"] == "error"
1225
1226 def test_base_not_found_error_mentions_ref(self, repo: pathlib.Path) -> None:
1227 code, out, _ = self._run_with_no_base(repo, fmt="json")
1228 data = json.loads(out.strip())
1229 assert "missing-base" in data["error"]
1230
1231 def test_base_not_found_text_uses_tick_prefix(self, repo: pathlib.Path) -> None:
1232 code, _, err = self._run_with_no_base(repo, fmt="text")
1233 assert "❌" in err
1234
1235
1236 # ─────────────────────────────────────────────────────────────────────────────
1237 # Compact JSON and conflicts_by_type
1238 # ─────────────────────────────────────────────────────────────────────────────
1239
1240
1241 class TestPlanMergeJsonOutput:
1242 """Verify JSON output shape, compactness, and new fields."""
1243
1244 def test_json_is_single_line(self, repo: pathlib.Path) -> None:
1245 """Output must be compact — no embedded newlines from indent=2."""
1246 _, out = _run_plan_merge(repo)
1247 lines = [ln for ln in out.splitlines() if ln.strip()]
1248 assert len(lines) == 1, f"JSON output must be a single line, got {len(lines)} lines"
1249
1250 def test_json_is_valid(self, repo: pathlib.Path) -> None:
1251 _, out = _run_plan_merge(repo)
1252 data = json.loads(out) # raises if invalid
1253 assert isinstance(data, dict)
1254
1255 def test_json_includes_conflicts_by_type_empty(self, repo: pathlib.Path) -> None:
1256 """When no conflicts, conflicts_by_type is an empty dict."""
1257 _, out = _run_plan_merge(repo)
1258 data = json.loads(out)
1259 assert "conflicts_by_type" in data
1260 assert data["conflicts_by_type"] == {}
1261
1262 def test_json_conflicts_by_type_single_type(self, repo: pathlib.Path) -> None:
1263 """Single conflict type → one entry in conflicts_by_type."""
1264 base = {"f.py::fn": _sym("fn", content_id="X", body_hash="BH0", signature_id="SIG")}
1265 ours = {"f.py::fn": _sym("fn", content_id="Y", body_hash="BH1", signature_id="SIG")}
1266 theirs = {"f.py::fn": _sym("fn", content_id="Z", body_hash="BH2", signature_id="SIG")}
1267 _, out = _run_plan_merge(
1268 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
1269 )
1270 data = json.loads(out)
1271 assert data["conflicts_by_type"].get("symbol_edit_overlap", 0) == 1
1272
1273 def test_json_conflicts_by_type_count_matches_conflicts_field(self, repo: pathlib.Path) -> None:
1274 """Sum of conflicts_by_type values must equal the 'conflicts' field."""
1275 N = 5
1276 base = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"X{i}") for i in range(N)}
1277 ours = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"Y{i}") for i in range(N)}
1278 theirs = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"Z{i}") for i in range(N)}
1279 _, out = _run_plan_merge(
1280 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
1281 )
1282 data = json.loads(out)
1283 assert sum(data["conflicts_by_type"].values()) == data["conflicts"]
1284
1285 def test_json_schema_includes_all_required_fields(self, repo: pathlib.Path) -> None:
1286 """Backward-compat check: all documented schema fields are present."""
1287 _, out = _run_plan_merge(repo)
1288 data = json.loads(out)
1289 required = {
1290 "schema_version", "ours", "theirs", "base", "base_auto_computed",
1291 "call_graph_available", "call_graph_skipped", "warnings",
1292 "total_symbols", "conflicts", "clean", "conflicts_by_type",
1293 "items", "elapsed_seconds",
1294 }
1295 missing = required - data.keys()
1296 assert not missing, f"Missing JSON fields: {missing}"
1297
1298 def test_json_items_contains_only_conflicts(self, repo: pathlib.Path) -> None:
1299 """items must contain only conflicting symbols, not clean ones."""
1300 base = {"f.py::fn": _sym("fn", content_id="X", body_hash="BH0", signature_id="SIG")}
1301 ours = {"f.py::fn": _sym("fn", content_id="Y", body_hash="BH1", signature_id="SIG")}
1302 theirs = {"f.py::fn": _sym("fn", content_id="X", body_hash="BH0", signature_id="SIG")}
1303 _, out = _run_plan_merge(
1304 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
1305 )
1306 data = json.loads(out)
1307 assert data["conflicts"] == 0
1308 assert data["items"] == []
1309
1310 def test_json_error_shape_has_status(self, repo: pathlib.Path) -> None:
1311 """Error JSON must include 'status' = 'error' (agent-parseable error shape)."""
1312 from muse.cli.commands.plan_merge import run as pm_run
1313 ns = argparse.Namespace(
1314 ours_ref="bad", theirs_ref="main",
1315 base_ref=None, skip_call_graph=True, fmt="json",
1316 )
1317 import io
1318 captured = io.StringIO()
1319 old = os.getcwd()
1320 os.chdir(repo)
1321 old_stdout = sys.stdout
1322 sys.stdout = captured
1323 try:
1324 with (
1325 patch("muse.cli.commands.plan_merge.require_repo", return_value=repo),
1326 patch("muse.cli.commands.plan_merge.read_repo_id", return_value="r"),
1327 patch("muse.cli.commands.plan_merge.read_current_branch", return_value="main"),
1328 patch("muse.cli.commands.plan_merge.resolve_commit_ref", return_value=None),
1329 ):
1330 with pytest.raises(SystemExit):
1331 pm_run(ns)
1332 finally:
1333 sys.stdout = old_stdout
1334 os.chdir(old)
1335 data = json.loads(captured.getvalue().strip())
1336 assert data["status"] == "error"
1337 assert "error" in data
1338
1339
1340 # ─────────────────────────────────────────────────────────────────────────────
1341 # Stress tests — conflicts_by_type correctness at scale
1342 # ─────────────────────────────────────────────────────────────────────────────
1343
1344
1345 class TestPlanMergeConflictsByTypeStress:
1346 def test_conflicts_by_type_counts_correct_at_scale(self, repo: pathlib.Path) -> None:
1347 """100 symbol_edit_overlap conflicts → conflicts_by_type["symbol_edit_overlap"] == 100."""
1348 N = 100
1349 base = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"X{i}") for i in range(N)}
1350 ours = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"Y{i}") for i in range(N)}
1351 theirs = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"Z{i}") for i in range(N)}
1352 _, out = _run_plan_merge(
1353 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
1354 )
1355 data = json.loads(out)
1356 assert data["conflicts_by_type"]["symbol_edit_overlap"] == N
1357 assert sum(data["conflicts_by_type"].values()) == N
1358
1359 def test_json_is_still_compact_with_many_items(self, repo: pathlib.Path) -> None:
1360 """Even with 500 conflict items, JSON output is a single line."""
1361 N = 500
1362 base = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"X{i}") for i in range(N)}
1363 ours = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"Y{i}") for i in range(N)}
1364 theirs = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"Z{i}") for i in range(N)}
1365 _, out = _run_plan_merge(
1366 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
1367 )
1368 lines = [ln for ln in out.splitlines() if ln.strip()]
1369 assert len(lines) == 1
1370
1371 def test_total_symbols_counts_union_of_all_three(self, repo: pathlib.Path) -> None:
1372 """total_symbols is |ours ∪ theirs ∪ base| — not just conflicts."""
1373 # 5 shared (conflicts) + 5 ours-only (clean) + 5 theirs-only (clean)
1374 base = {f"f.py::shared{i}": _sym(f"shared{i}") for i in range(5)}
1375 ours_extra = {f"f.py::ours{i}": _sym(f"ours{i}") for i in range(5)}
1376 theirs_extra = {f"f.py::theirs{i}": _sym(f"theirs{i}") for i in range(5)}
1377 # Make shared symbols conflict
1378 ours_syms = {
1379 **{f"f.py::shared{i}": _sym(f"shared{i}", content_id=f"Y{i}") for i in range(5)},
1380 **ours_extra,
1381 }
1382 theirs_syms = {
1383 **{f"f.py::shared{i}": _sym(f"shared{i}", content_id=f"Z{i}") for i in range(5)},
1384 **theirs_extra,
1385 }
1386 _, out = _run_plan_merge(
1387 repo, mock_ours_syms=ours_syms, mock_theirs_syms=theirs_syms, mock_base_syms=base,
1388 )
1389 data = json.loads(out)
1390 assert data["total_symbols"] == 15
1391 assert data["conflicts"] == 5
1392 assert data["clean"] == 10
1393
1394
1395 # ─────────────────────────────────────────────────────────────────────────────
1396 # E2E tests — same-commit and multi-type plans
1397 # ─────────────────────────────────────────────────────────────────────────────
1398
1399
1400 class TestPlanMergeE2E:
1401 """E2E-style integration tests using mock commits that simulate real scenarios."""
1402
1403 def test_same_symbols_on_both_branches_zero_conflicts(self, repo: pathlib.Path) -> None:
1404 """Identical symbol trees → no conflicts, all clean."""
1405 syms = {f"f.py::fn{i}": _sym(f"fn{i}") for i in range(20)}
1406 _, out = _run_plan_merge(
1407 repo, mock_ours_syms=syms, mock_theirs_syms=syms, mock_base_syms=syms,
1408 )
1409 data = json.loads(out)
1410 assert data["conflicts"] == 0
1411 assert data["total_symbols"] == 20
1412 assert data["conflicts_by_type"] == {}
1413
1414 def test_disjoint_changes_no_conflicts(self, repo: pathlib.Path) -> None:
1415 """Ours and theirs each modify different symbols → all no_conflict."""
1416 base = {f"f.py::fn{i}": _sym(f"fn{i}", content_id=f"BASE{i}") for i in range(10)}
1417 ours = dict(base)
1418 theirs = dict(base)
1419 # Ours modifies 0–4, theirs modifies 5–9
1420 for i in range(5):
1421 ours[f"f.py::fn{i}"] = _sym(f"fn{i}", content_id=f"OUR{i}")
1422 for i in range(5, 10):
1423 theirs[f"f.py::fn{i}"] = _sym(f"fn{i}", content_id=f"THEIR{i}")
1424 _, out = _run_plan_merge(
1425 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
1426 )
1427 data = json.loads(out)
1428 assert data["conflicts"] == 0
1429
1430 def test_one_new_symbol_on_each_branch_no_conflict(self, repo: pathlib.Path) -> None:
1431 """Each branch adds a different new symbol → no overlap → no conflict."""
1432 base: SymbolTree = {}
1433 ours = {"f.py::fn_ours": _sym("fn_ours")}
1434 theirs = {"f.py::fn_theirs": _sym("fn_theirs")}
1435 _, out = _run_plan_merge(
1436 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
1437 )
1438 data = json.loads(out)
1439 assert data["conflicts"] == 0
1440
1441 def test_both_add_same_symbol_different_content_conflict(self, repo: pathlib.Path) -> None:
1442 """Both branches add the same address with different content → conflict."""
1443 base: SymbolTree = {}
1444 ours = {"f.py::fn_new": _sym("fn_new", content_id="OUR", body_hash="BH1")}
1445 theirs = {"f.py::fn_new": _sym("fn_new", content_id="THEIR", body_hash="BH2")}
1446 _, out = _run_plan_merge(
1447 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
1448 )
1449 data = json.loads(out)
1450 assert data["conflicts"] == 1
1451
1452 def test_both_add_same_symbol_same_content_no_conflict(self, repo: pathlib.Path) -> None:
1453 """Both branches add the same symbol with identical content → no conflict."""
1454 base: SymbolTree = {}
1455 sym = _sym("fn_new", content_id="SAME")
1456 _, out = _run_plan_merge(
1457 repo, mock_ours_syms={"f.py::fn_new": sym},
1458 mock_theirs_syms={"f.py::fn_new": sym},
1459 mock_base_syms=base,
1460 )
1461 data = json.loads(out)
1462 assert data["conflicts"] == 0
1463
1464 def test_both_delete_same_symbol_no_conflict(self, repo: pathlib.Path) -> None:
1465 """Both branches delete the same symbol → no conflict."""
1466 base = {"f.py::fn": _sym("fn")}
1467 ours: SymbolTree = {}
1468 theirs: SymbolTree = {}
1469 _, out = _run_plan_merge(
1470 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
1471 )
1472 data = json.loads(out)
1473 assert data["conflicts"] == 0
1474
1475 def test_rename_edit_and_overlap_in_same_plan(self, repo: pathlib.Path) -> None:
1476 """A plan with both rename_edit and symbol_edit_overlap appears correctly in conflicts_by_type."""
1477 body = "UNIQUEBODY12345"
1478 # Symbol A: ours renames it, theirs modifies original → rename_edit
1479 base_a = {"f.py::fn_a_old": {**_sym("fn_a_old", body_hash=body), "name": "fn_a_old"}}
1480 ours_a = {"f.py::fn_a_new": {**_sym("fn_a_new", body_hash=body), "name": "fn_a_new"}}
1481 theirs_a = {"f.py::fn_a_old": {**_sym("fn_a_old", content_id="Z", body_hash="DIFF"), "name": "fn_a_old"}}
1482 # Symbol B: both change differently → symbol_edit_overlap
1483 base_b = {"f.py::fn_b": _sym("fn_b", content_id="X", body_hash="BH0", signature_id="SIG")}
1484 ours_b = {"f.py::fn_b": _sym("fn_b", content_id="Y", body_hash="BH1", signature_id="SIG")}
1485 theirs_b = {"f.py::fn_b": _sym("fn_b", content_id="Z2", body_hash="BH2", signature_id="SIG")}
1486
1487 base = {**base_a, **base_b}
1488 ours = {**ours_a, **ours_b}
1489 theirs = {**theirs_a, **theirs_b}
1490
1491 _, out = _run_plan_merge(
1492 repo, mock_ours_syms=ours, mock_theirs_syms=theirs, mock_base_syms=base,
1493 )
1494 data = json.loads(out)
1495 cbt = data["conflicts_by_type"]
1496 assert cbt.get("rename_edit", 0) >= 1
1497 assert cbt.get("symbol_edit_overlap", 0) >= 1
1498 assert sum(cbt.values()) == data["conflicts"]
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago